Skip to main content

Command Palette

Search for a command to run...

DDD and Vertical Slice Architecture Are Friends, Not Rivals

Published
15 min read
DDD and Vertical Slice Architecture Are Friends, Not Rivals
P
Senior Software Engineer specialising in cloud architecture, distributed systems, and modern .NET development, with over two decades of experience designing and delivering enterprise platforms in financial, insurance, and high-scale commercial environments. My focus is on building systems that are reliable, scalable, and maintainable over the long term. I’ve led modernisation initiatives moving legacy platforms to cloud-native Azure architectures, designed high-throughput streaming solutions to eliminate performance bottlenecks, and implemented secure microservices environments using container-based deployment models and event-driven integration patterns. From an architecture perspective, I have strong practical experience applying approaches such as Vertical Slice Architecture, Domain-Driven Design, Clean Architecture, and Hexagonal Architecture. I’m particularly interested in modular system design that balances delivery speed with long-term sustainability, and I enjoy solving complex problems involving distributed workflows, performance optimisation, and system reliability. I enjoy mentoring engineers, contributing to architectural decisions, and helping teams simplify complex systems into clear, maintainable designs. I’m always open to connecting with other engineers, architects, and technology leaders working on modern cloud and distributed system challenges.

A lot of Developers talk about Domain Driven Design and Vertical Slice Architecture as if they are competing choices. They're really not. DDD helps you model the business. Vertical Slice Architecture helps you organise the application around behaviour. One is about understanding and protecting the domain. The other is about shaping the application so business operations are easy to find, change, test, and deploy.

The confusion usually starts when people compare them as folder structures. They look at a traditional layered architecture with Domain, Application, Infrastructure, and API, then they look at vertical slices grouped by feature, and assume they must choose one.

That is the wrong comparison. DDD is not a folder layout. Vertical Slice Architecture is not a domain model. They solve different problems, and a serious system can use both, In fact, they work better together.

The false choice

Traditional layered architecture usually starts with technical separation. Controllers go in one place. Services go in another. Repositories go somewhere else. Validators, DTOs, mappings, and persistence models all get their own homes.

That can work, but it often creates a poor development experience. To change one business capability, you jump across several folders or projects. The code is technically separated, but the feature itself is scattered. Vertical Slice Architecture flips that around. It says a business operation should mostly live together. If the user reserves cinema seats, the endpoint, command, validator, handler, response, and tests should sit close to each other.

Thats useful, but it doesn't automatically give you a good domain model.

A vertical slice can still be procedural. It can still be a transaction script with all the business rules buried in a handler. It can still mutate EF entities directly. It can still turn every business operation into a thin CRUD wrapper. This is where DDD comes in.

Vertical slices give your use cases a home. DDD gives your business rules a home.

The handler should coordinate the work. The domain model should make the business decision.

That distinction sounds small. It is not. It is the difference between a system that merely stores data and a system that protects business meaning.

https://www.youtube.com/watch?v=caxS7806es0

Vertical slices organise behaviour

A vertical slice should represent a useful business action. Not a table. Not a repository. Not a generic service. A business action.

ReserveSeats is better than BookingService.Update.

ConfirmBookingPayment is better than PatchBookingStatus.

CancelReservation is better than SetReservationInactive.

The name of the slice should tell you what the system is doing from the business point of view. This is one of the places where DDD and Vertical Slice Architecture naturally support each other. DDD pushes you towards business language. Vertical Slice Architecture gives that language a clear place in the codebase.

A slice for reserving cinema seats might look like this.

Features/
  SeatReservations/
    ReserveSeats/
      ReserveSeatsEndpoint.cs
      ReserveSeatsCommand.cs
      ReserveSeatsHandler.cs
      ReserveSeatsResponse.cs
      ReserveSeatsValidator.cs
      ReserveSeatsTests.cs

That structure is not the domain model. It is the application boundary around one use case.

Inside that slice, the handler should load the aggregate, call domain behaviour, persist the result, and return a response. It should not become the place where every rule lives.

The handler is not the domain

This is the common failure.

A team adopts vertical slices. The folders look clean. The endpoints are small. The feature boundaries look sensible. Then every handler grows into a wall of conditional logic.

The code starts like this.

internal sealed class ReserveSeatsHandler(CinemaDbContext db)
    : ICommandHandler<ReserveSeatsCommand, ReserveSeatsResponse>
{
    public async Task<Result<ReserveSeatsResponse>> Handle(
        ReserveSeatsCommand command,
        CancellationToken stopToken)
    {
        var screening = await db.Screenings
            .Include(x => x.Reservations)
            .SingleOrDefaultAsync(x => x.Id == command.ScreeningId, stopToken);

        if (screening is null)
        {
            return Error.NotFound(
                "Screening.NotFound",
                "The requested screening could not be found.");
        }

        if (screening.StartsAt <= DateTimeOffset.UtcNow)
        {
            return Error.Conflict(
                "Screening.AlreadyStarted",
                "Seats cannot be reserved after the screening has started.");
        }

        var requestedSeats = command.SeatNumbers.ToHashSet(StringComparer.OrdinalIgnoreCase);

        var alreadyReserved = screening.Reservations
            .Where(x => requestedSeats.Contains(x.SeatNumber))
            .Where(x => x.ExpiresAt > DateTimeOffset.UtcNow)
            .Select(x => x.SeatNumber)
            .ToArray();

        if (alreadyReserved.Length > 0)
        {
            return Error.Conflict(
                "Seats.AlreadyReserved",
                "One or more selected seats are no longer available.");
        }

        if (requestedSeats.Count > 6)
        {
            return Error.Validation(
                "Seats.TooManySelected",
                "A customer can reserve a maximum of six seats.");
        }

        var reservation = new SeatReservation
        {
            Id = Guid.NewGuid(),
            ScreeningId = screening.Id,
            CustomerId = command.CustomerId,
            SeatNumbers = requestedSeats.ToArray(),
            ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10),
            Status = "Held"
        };

        db.SeatReservations.Add(reservation);

        await db.SaveChangesAsync(stopToken);

        return new ReserveSeatsResponse(
            reservation.Id,
            reservation.ExpiresAt);
    }
}

This is vertical slice code, but it is not strong domain code.

The slice has become the model. The handler knows when a screening can accept reservations. It knows the seat limit. It knows how seat holds expire. It knows what Held means. It knows which existing reservations block a new one. It knows how long the reservation lasts.

That works for a while. Then another feature needs the same rules. A mobile endpoint needs them. A kiosk needs them. An admin flow needs them. A background job needs to rebuild reservations after a payment provider outage. Each one copies part of the logic, slightly differently.

That is how business rules leak.

Put business behaviour in the model

A better version keeps the slice focused on orchestration and pushes business decisions into the aggregate. The handler should not decide whether seats can be reserved. The aggregate should decide that.

internal sealed class Screening
{
    private readonly List<SeatReservation> _reservations = [];

    private Screening()
    {
    }

    public Screening(
        ScreeningId id,
        DateTimeOffset startsAt,
        Auditorium auditorium)
    {
        Id = id;
        StartsAt = startsAt;
        Auditorium = auditorium;
    }

    public ScreeningId Id { get; private set; } = null!;

    public DateTimeOffset StartsAt { get; private set; }

    public Auditorium Auditorium { get; private set; } = null!;

    public IReadOnlyCollection<SeatReservation> Reservations => _reservations.AsReadOnly();

    public Result<SeatReservation> ReserveSeats(
        CustomerId customerId,
        IReadOnlyCollection<SeatNumber> requestedSeats,
        DateTimeOffset now)
    {
        if (StartsAt <= now)
        {
            return ScreeningErrors.AlreadyStarted(Id);
        }

        if (requestedSeats.Count > 6)
        {
            return ScreeningErrors.TooManySeatsSelected(Id);
        }

        if (!Auditorium.ContainsAll(requestedSeats))
        {
            return ScreeningErrors.InvalidSeatSelection(Id);
        }

        var unavailableSeats = _reservations
            .Where(x => x.IsActiveAt(now))
            .SelectMany(x => x.Seats)
            .Intersect(requestedSeats)
            .ToArray();

        if (unavailableSeats.Length > 0)
        {
            return ScreeningErrors.SeatsAlreadyReserved(Id, unavailableSeats);
        }

        var reservation = SeatReservation.Hold(
            Id,
            customerId,
            requestedSeats,
            now.AddMinutes(10));

        _reservations.Add(reservation);

        Raise(new SeatsReserved(
            Id.Value,
            reservation.Id.Value,
            customerId.Value,
            requestedSeats.Select(x => x.Value).ToArray()));

        return reservation;
    }
}

Now the handler changes shape.

internal sealed class ReserveSeatsHandler(CinemaDbContext db, TimeProvider clock)
    : ICommandHandler<ReserveSeatsCommand, ReserveSeatsResponse>
{
    public async Task<Result<ReserveSeatsResponse>> Handle(
        ReserveSeatsCommand command,
        CancellationToken stopToken)
    {
        var screening = await db.Screenings
            .Include(x => x.Reservations)
            .SingleOrDefaultAsync(x => x.Id == new ScreeningId(command.ScreeningId), stopToken);

        if (screening is null)
        {
            return Error.NotFound(
                "Screening.NotFound",
                "The requested screening could not be found.");
        }

        var requestedSeatsResult = SeatNumber.CreateMany(command.SeatNumbers);

        if (requestedSeatsResult.IsFailure)
        {
            return requestedSeatsResult.Error;
        }

        var reserveResult = screening.ReserveSeats(
            new CustomerId(command.CustomerId),
            requestedSeatsResult.Value,
            clock.GetUtcNow());

        if (reserveResult.IsFailure)
        {
            return reserveResult.Error;
        }

        await db.SaveChangesAsync(stopToken);

        return new ReserveSeatsResponse(
            reserveResult.Value.Id.Value,
            reserveResult.Value.ExpiresAt);
    }
}

This is still a vertical slice. The endpoint, command, handler, response, validator, and tests can still live together. But the rules that define seat reservation live in the domain model.

Thats the balance.

The slice owns the use case. The aggregate owns the business behaviour.

DDD gives slices a spine

Without DDD, vertical slices can become isolated scripts. Each slice does its job, but the domain has no centre of gravity. Business rules spread sideways across handlers.

DDD gives those slices a spine.

The domain model becomes the place where important concepts live. Aggregates protect consistency. Value objects protect meaning. Domain events describe important facts. Policies make complex decisions explicit. Errors use business language instead of technical failure messages.

The vertical slice still matters because it keeps the application flow readable. You can open one folder and understand how a business operation enters the system, what it validates, what it loads, what it changes, and what it returns.

But the slice should not become a dumping ground. A good slice should answer this question: what does this use case do?

A good domain model should answer this question: what is allowed to happen?

Those are different questions.

Aggregates still have a place in vertical slices

You might think Vertical Slice Architecture removes the need for aggregates because each feature is already isolated. Thats a mistake. A slice is not a consistency boundary. It is an application boundary.

An aggregate is a consistency boundary. It protects rules that must be true after a transaction completes.

For a cinema system, the Screening aggregate may protect seat availability, reservation expiry, reservation limits, auditorium rules, and whether the screening has already started. A Booking aggregate may protect payment confirmation, refund rules, ticket issuing, and cancellation rules.

The slice should not manually enforce those invariants. It should ask the aggregate to perform the operation.

This is where vertical slices and DDD fit neatly together. Each slice can use the aggregate in a focused way. The aggregate remains the authority for its own rules.

Value objects reduce primitive obsession

Vertical slices often expose primitive values at the edge of the system. That is normal. HTTP requests, JSON bodies, route values, and query strings usually enter the application as strings, numbers, and booleans.

The problem starts when those primitives flow all the way into the domain.

A string called SeatNumber is not always just a string. It may need to match the cinema layout. It may need to preserve row and seat semantics. It may need to reject values that look valid but do not exist in the room.

A Guid called ScreeningId is not just a random identifier. It identifies a scheduled showing of a film in a specific auditorium at a specific time. Treating it as a naked primitive everywhere weakens the model.

Value objects give those ideas a name.

internal sealed record SeatNumber
{
    private SeatNumber(string value)
    {
        Value = value;
    }

    public string Value { get; }

    public static Result<SeatNumber> Create(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return Error.Validation(
                "SeatNumber.Empty",
                "Seat number is required.");
        }

        var normalised = value.Trim().ToUpperInvariant();

        if (!Regex.IsMatch(normalised, "^[A-Z][1-9][0-9]?$"))
        {
            return Error.Validation(
                "SeatNumber.Invalid",
                "Seat number must use a valid format such as A1 or C12.");
        }

        return new SeatNumber(normalised);
    }

    public static Result<IReadOnlyCollection<SeatNumber>> CreateMany(
        IReadOnlyCollection<string> values)
    {
        var seats = new List<SeatNumber>();

        foreach (var value in values)
        {
            var result = Create(value);

            if (result.IsFailure)
            {
                return result.Error;
            }

            seats.Add(result.Value);
        }

        return seats;
    }
}

The vertical slice can still accept string[] seatNumbers from the request. But the domain should not treat those strings as casual text. Once they cross into the model, they should become domain concepts.

That is one of the simplest ways to improve a vertical-slice codebase. Keep primitives at the boundary. Convert them into meaningful types before they reach business behaviour.

Application validation and domain rules are different

Vertical slices often have validators. Thats good, but validators should not replace domain rules.

A command validator can check whether a request is structurally valid. It can check whether required fields are present, whether an array is empty, or whether a route value has the right shape. The domain model should protect business truth.

A validator can say, “At least one seat must be selected.”

The aggregate should say, “These seats cannot be reserved because they are already held by another active reservation.”

Those rules live at different levels.

When teams mix these up, the validator becomes a second domain model. That creates weak design because validators usually sit outside the domain and are easy to bypass.

The domain should protect itself even when a different entry point calls it.

That might be an API endpoint today. Tomorrow it might be a kiosk flow, a mobile app, a queue consumer, a scheduled cleanup job, a support tool, or a data repair process.

The model should not depend on the current slice being the only caller.

Domain events fit naturally inside slices

Domain events also fit well with vertical slices, as long as you keep the distinction clear.

A domain event describes something meaningful that happened inside the model. It should use business language. It should not be designed around a message broker, storage queue, or external contract.

SeatsReserved is a domain event. It says something happened in the business.

The slice can persist the aggregate and let an outbox, dispatcher, or post-commit pipeline handle the event later. The important thing is that the domain event comes from the model, not from the handler guessing what changed.

internal sealed record SeatsReserved(
    Guid ScreeningId,
    Guid ReservationId,
    Guid CustomerId,
    string[] SeatNumbers) : IDomainEvent;

Inside the aggregate, the event is raised when the business operation succeeds.

public Result<SeatReservation> ReserveSeats(
    CustomerId customerId,
    IReadOnlyCollection<SeatNumber> requestedSeats,
    DateTimeOffset now)
{
    if (StartsAt <= now)
    {
        return ScreeningErrors.AlreadyStarted(Id);
    }

    if (requestedSeats.Count > 6)
    {
        return ScreeningErrors.TooManySeatsSelected(Id);
    }

    if (!Auditorium.ContainsAll(requestedSeats))
    {
        return ScreeningErrors.InvalidSeatSelection(Id);
    }

    var unavailableSeats = _reservations
        .Where(x => x.IsActiveAt(now))
        .SelectMany(x => x.Seats)
        .Intersect(requestedSeats)
        .ToArray();

    if (unavailableSeats.Length > 0)
    {
        return ScreeningErrors.SeatsAlreadyReserved(Id, unavailableSeats);
    }

    var reservation = SeatReservation.Hold(
        Id,
        customerId,
        requestedSeats,
        now.AddMinutes(10));

    _reservations.Add(reservation);

    Raise(new SeatsReserved(
        Id.Value,
        reservation.Id.Value,
        customerId.Value,
        requestedSeats.Select(x => x.Value).ToArray()));

    return reservation;
}

The vertical slice does not need to know how to describe that event. It only needs to complete the use case. That keeps the business language inside the domain and the orchestration inside the slice.

The folder structure is less important than the dependency direction

People get very religious about folders.

You can put domain types in a shared domain folder. You can put feature-specific domain types near the slice. You can group by module. You can use projects. You can use folders. None of that matters as much as dependency direction and business ownership.

The application slice can depend on the domain. The domain should not depend on the application slice.

The domain should not know about endpoints, DTOs, EF Core queries, HTTP, queues, JSON, or request models.

That simple rule prevents a lot of damage.

Once the domain starts depending on application concerns, it becomes harder to test, harder to reuse, and harder to reason about. The model stops being a model and becomes a reflection of the current delivery mechanism.

EF Core should persist the model, not design it

EF Core is a good tool, but it should not become the architect of your domain.

The model should express business rules first. Persistence mapping should support that model. That usually means some extra mapping work, but the tradeoff is worth it when the domain has real behaviour.

For example, Screening can expose reservations as a read-only collection while EF Core maps the backing field.

internal sealed class ScreeningConfiguration : IEntityTypeConfiguration<Screening>
{
    public void Configure(EntityTypeBuilder<Screening> builder)
    {
        builder.HasKey(x => x.Id);

        builder.Property(x => x.Id)
            .HasConversion(
                id => id.Value,
                value => new ScreeningId(value));

        builder.Property(x => x.StartsAt);

        builder.HasMany<SeatReservation>("_reservations")
            .WithOne()
            .HasForeignKey(x => x.ScreeningId);

        builder.Navigation("_reservations")
            .UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}

This kind of mapping keeps the domain model honest. External code cannot casually mutate the reservation list, but EF Core can still load and persist it.

That is the right relationship. The domain owns behaviour. EF Core handles storage.

Vertical slices make DDD easier to adopt gradually

One of the best things about Vertical Slice Architecture is that it lets you adopt DDD gradually. You dont need to stop everything and design a perfect domain model upfront. You can improve one use case at a time.

Start with a handler that contains too much business logic. Move one rule into the aggregate. Extract one value object. Rename one command to reflect business intent. Replace one generic update operation with a meaningful method. Add one domain event where a real business fact occurs.

Thats how real systems improve.

DDD fails when you turn it into a ceremony. You spend weeks arguing about repositories, factories, aggregates, and bounded contexts before improving the code. Vertical slices help avoid that because you keep the work close to a business outcome.

Pick a use case. Model it better. Move on.

Where this goes wrong

The first mistake is treating every vertical slice as an excuse to duplicate domain logic. That gives you local simplicity and global chaos. The code feels clean inside each folder, but the system becomes inconsistent over time.

The second mistake is creating a domain model that does not do anything. If your entities only have getters and setters, and all decisions happen in handlers, you do not really have a domain model. You have persistence objects.

The third mistake is over-modelling everything. Not every feature needs a rich aggregate, value objects, policies, and domain events. Some slices are simple queries. Some operations are basic administration tasks. Some parts of the system are CRUD, and pretending otherwise only adds noise.

The fourth mistake is letting EF Core dictate the model. EF Core should persist the model, not flatten it into a set of public setters and navigation properties because that is easier to map.

The fifth mistake is confusing module boundaries with aggregate boundaries. A module can contain many slices and several aggregates. A slice can use one aggregate, multiple read models, and infrastructure services. These concepts overlap, but they are not the same thing.

A practical way to think about it

Use vertical slices to organise use cases.

Use DDD to model business behaviour.

Use aggregates to protect consistency.

Use value objects to give meaning to important values.

Use domain events to describe facts that happened.

Use handlers to coordinate, not to own the business.

When a request comes in, the slice handles the application flow. When a business decision needs to be made, the domain model makes it. The result is code that is easier to navigate and harder to corrupt.

https://www.youtube.com/watch?v=8Z5IAkWcnIw&list=PLzYkqgWkHPKDpXETRRsFv2F9ht6XdAF3v

DDD and Vertical Slice Architecture are not rivals. They're complementary tools. Vertical slices stop your features from being scattered across technical layers. DDD stops your business rules from being scattered across procedural handlers. Together, they give you a structure that works with the way software actually changes. Features change because the business changes. Rules change because the business changes. Language changes because the business changes. Your architecture should make those changes easier to understand, safer to implement, and harder to get subtly wrong.