Skip to main content
SEMastery
Architecturebeginner

Vertical Slice Architecture: Where Does the Shared Logic Live?

A beginner-friendly guide to where shared logic lives in Vertical Slice Architecture in .NET — domain, infrastructure, pipelines, and the rule of three.

12 min readUpdated February 8, 2026

A shared kitchen in a hostel

Picture a student hostel with many rooms. Each room is like a little home. The students cook their own special dishes in their own rooms. One room loves spicy biryani. Another room makes simple khichdi. Each room owns its own recipe and its own taste.

But the hostel still has one shared kitchen downstairs. That kitchen has the big gas stove, the water tap, the fridge, and the dustbin. Nobody puts their secret recipe in the shared kitchen. They only keep the common tools there, like the stove and the tap, because every room needs those.

This is exactly how Vertical Slice Architecture (VSA) works. Each feature is like a room with its own recipe. The shared kitchen holds only the common tools that everyone needs. The big question this article answers is simple: which things go in the room, and which things go in the shared kitchen?

What a slice actually is

In VSA, you organise your code by feature, not by technical layer. A slice is one full feature, top to bottom. It holds the request, the validation, the business rule, and the database call, all in one place.

Compare this with the old layered way. In the layered way, you have a Controllers folder, a Services folder, and a Repositories folder. To add one feature, you touch all three folders. The code for one feature is scattered.

In VSA, the code for one feature sits together in one folder. You change a feature by opening one place.

Layered architecture spreads one feature across many folders, while a vertical slice keeps one feature together.

When each feature lives on its own, a natural worry appears. If features do not share folders, do they end up copying code everywhere? And when something really is shared, where should it go? Let us answer that step by step.

Three kinds of "shared"

The word "shared" is too broad. It hides three very different things. Before deciding where shared code lives, you must know which kind of sharing you are looking at.

Kind of shared codeExampleChanges per feature?Where it lives
Technical plumbingLogging, database context, cachingAlmost neverInfrastructure or Shared
Cross-cutting behaviorValidation, auth checks, timingRarelyPipeline behaviors or middleware
Business logicPricing rules, order calculationsOftenDomain, and only when truly shared

The trick is to treat each kind differently. You share technical plumbing freely. You share cross-cutting behavior through pipelines. You share business logic very carefully, and only when it is genuinely the same.

Deciding where shared code belongs

Is it technical?
Is it cross-cutting?
Is it the same rule 3x?
Keep it in the slice

Steps

1

Is it technical?

Yes: put it in Infrastructure/Shared

2

Is it cross-cutting?

Yes: put it in a pipeline behavior

3

Is it the same rule 3x?

Yes: move it to Domain

4

Keep it in the slice

No to all: leave it where it is

A short decision path for any piece of code you think is shared.

Technical plumbing goes in the shared kitchen

Some code has nothing to do with any one feature. The database connection. The logger. The HTTP client that calls a payment provider. The clock that gives the current time.

This is the stove and the tap of your app. Every slice needs it, and it rarely changes when a feature changes. So you set it up once and let every slice use it.

In a typical .NET app, this plumbing is registered once at startup.

// Program.cs — shared plumbing set up once for the whole app
var builder = WebApplication.CreateBuilder(args);
 
// Database context: shared by every slice
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
 
// A shared clock so slices do not call DateTime.Now directly
builder.Services.AddSingleton<IClock, SystemClock>();
 
// A shared HTTP client for a payment provider
builder.Services.AddHttpClient<IPaymentGateway, StripePaymentGateway>();
 
var app = builder.Build();

Notice what is shared here. It is the wiring, not the rules. Sharing a database context does not couple your features together, because two slices can both read from the same database while keeping totally different logic. The stove is shared. The recipe is not.

Cross-cutting concerns go in pipeline behaviors

Some things must happen on every request. You want to log every call. You want to validate the input. You want to check that the user is allowed in. You do not want to copy that code into every single handler. That would be slow to write and easy to get wrong.

The clean answer is a pipeline. A pipeline wraps around your handler. It runs before and after the real work, like a security guard at a gate. Every request passes through the same guard, but the guard does not know or care what each room cooks.

A request flows through shared pipeline behaviors before reaching the slice handler.

Here is a small validation behavior. It runs for every request, so each slice stays free of repeated validation plumbing.

// A shared pipeline behavior that validates any request before the handler runs.
public sealed class ValidationBehavior<TRequest, TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;
 
    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
        => _validators = validators;
 
    public async Task<TResponse> Handle(
        TRequest request,
        Func<Task<TResponse>> next,
        CancellationToken ct)
    {
        foreach (var validator in _validators)
        {
            var result = await validator.ValidateAsync(request, ct);
            if (!result.IsValid)
                throw new ValidationException(result.Errors);
        }
 
        // All checks passed, so let the real slice handler run.
        return await next();
    }
}

A quick note on tooling. Many tutorials use a library called MediatR to build these pipelines, and MassTransit for messaging. As of late 2024 both moved to a commercial license for larger companies. They still work well, but they are no longer free for every business. You do not have to use them. You can build a simple pipeline yourself with a small interface, or use a free community alternative. The idea of a pipeline matters far more than the brand of library.

Business logic is the careful part

This is where most mistakes happen. A new student sees two slices that look alike and thinks, "I will pull this into a shared service to be DRY." Then six months later, the two features need to change in different ways, and the shared service becomes a knot that hurts both.

The safe rule is the rule of three. Wait until three slices truly do the same thing before you extract it. One copy is normal. Two copies might be a coincidence. Three copies is a real pattern.

The rule of three for extracting business logic

1 copy
2 copies
3 copies
Extract to Domain

Steps

1

1 copy

Normal: leave it in the slice

2

2 copies

Maybe a coincidence: wait

3

3 copies

A real pattern has appeared

4

Extract to Domain

Now move it and reuse it

Count real, identical usages before you move logic into a shared place.

When business logic really is shared, it belongs in a Domain folder. The Domain holds entities, value objects, and a few domain services. A Customer entity, an Order entity, a Money value object that knows how to add amounts safely. These are shared because they are the true meaning of your business, not just a convenience.

// A value object in the Domain folder, shared by many slices.
// It protects a real business rule: you cannot add two different currencies.
public readonly record struct Money(decimal Amount, string Currency)
{
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException(
                "Cannot add money in different currencies.");
 
        return this with { Amount = Amount + other.Amount };
    }
}

This is safe to share because the rule "you cannot add rupees to dollars" is true for every feature. It will not change just because one feature changes.

The folder structure that ties it together

Here is a folder layout that puts each kind of code in its right home. Notice the small Shared folder inside a feature. That is for logic shared by only the slices of that one feature, like a small helper used by both CreateOrder and UpdateOrder.

FolderWhat it holdsWho uses it
Features/Orders/CreateOrderOne full sliceOnly this slice
Features/Orders/SharedHelpers for Order slicesOnly Order slices
DomainEntities, value objects, rulesMany features
InfrastructureDatabase, clients, loggingThe whole app
CommonPipeline behaviors, Result typeThe whole app
A folder map showing where each kind of shared code lives in a Vertical Slice project.

Most "sharing" is just reading data

Here is a surprise that trips up many people. A lot of what looks like shared logic is really just reading data from the same database.

Say CreateOrder needs the customer's address. The wrong move is to call into a "Customers feature service." That ties two features together with a hidden rope. The right move is simple: the CreateOrder slice reads the customer row from the database itself. It owns its own data access.

Each slice talks to the shared database directly. It does not talk to other slices. The database is the shared kitchen tap. Two rooms can both fill water from the same tap without becoming the same room.

// The CreateOrder slice reads the data it needs directly.
// It does NOT call into a "Customers" feature service.
public async Task<OrderId> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
    // Read shared data straight from the database.
    var customer = await _db.Customers
        .FirstOrDefaultAsync(c => c.Id == cmd.CustomerId, ct)
        ?? throw new NotFoundException("Customer not found.");
 
    var order = Order.Create(customer.Id, cmd.Items);
    _db.Orders.Add(order);
    await _db.SaveChangesAsync(ct);
 
    return order.Id;
}

This keeps coupling low. If CreateOrder changes how it reads a customer tomorrow, no other slice breaks.

The trap to avoid: the dumping ground

There is one common mistake worth naming clearly. The Common or Shared folder can slowly turn into a dumping ground. Someone adds a DTO there "just for now." Someone else adds a helper. Soon every slice imports from Common, and your slices are quietly glued together again. You are back to the tangled layered design you tried to leave.

To avoid this, keep two habits. First, each slice should define its own request and response shapes, even if they look similar to another slice. Second, only put something in Common if it is truly technical and truly used everywhere, like a pipeline behavior or a Result type. If you are unsure, leave it in the slice. A little repeated code is cheaper than the wrong shared code.

A worked example, end to end

Let us walk through one request to see every kind of shared code in action. A user sends a request to create an order.

A create-order request, end to end

Request in
Logging behavior
Validation behavior
Slice handler
Domain rule
Database

Steps

1

Request in

User posts new order

2

Logging behavior

Shared plumbing logs it

3

Validation behavior

Shared pipeline checks input

4

Slice handler

Feature-only logic runs

5

Domain rule

Money value object adds totals safely

6

Database

Shared context saves the order

Each kind of shared code plays its role without coupling the slice to other features.

In that single trip, the request touched shared logging, a shared validation pipeline, the slice's own handler, a shared domain rule, and the shared database context. Yet the CreateOrder slice never called another feature. Every shared piece was either technical plumbing or a true domain rule. That is the balance VSA is aiming for.

When to bend the rules

No rule fits every case. Here are a few honest exceptions.

If your app is tiny, a heavy Domain folder and many pipeline behaviors may be overkill. Start simple. Add structure only when the pain of not having it is real.

If two slices share a rule that is clearly stable and central to your business, you may extract it before you hit three copies. Judgement matters more than a strict count. The rule of three is a guide, not a law.

If you find yourself fighting the framework to keep slices apart, step back. The goal is low coupling and easy change, not purity for its own sake. A small, sensible shared helper beats a clever abstraction that nobody understands.

Quick recap

  • A slice is one full feature, kept together from request to database. Organise by feature, not by technical layer.
  • "Shared" hides three kinds of code: technical plumbing, cross-cutting behavior, and business logic. Treat each one differently.
  • Technical plumbing, like the database context, logger, and HTTP clients, is shared freely and set up once at startup.
  • Cross-cutting concerns, like logging and validation, live in pipeline behaviors or middleware so you write them once.
  • Business logic is shared carefully. Use the rule of three and put truly shared rules in a Domain folder of entities and value objects.
  • Most "sharing" is just reading the same data. Let each slice read the database directly instead of calling other features.
  • Watch out for the Common dumping ground. A little duplication is cheaper than the wrong shared code.
  • Tools like MediatR and MassTransit are now commercially licensed for larger companies. The pipeline idea matters more than any one library.

References and further reading

Related Posts