Mert Özen Further With Every Line
Software Architecture Vertical Slice Architecture — #04

Sharing Across Slices: Duplication or Abstraction, and Where Does Domain Logic Go?

Mert Özen Nov 7, 2026 11 min 4 views
Sharing Across Slices: Duplication or Abstraction, and Where Does Domain Logic Go?

Across slices you share the domain model, cross-cutting concerns, and infrastructure; you don't share request/response types, queries, or workflows, and invariants move into the entity without waiting for a second copy. With real output we see a rule placed on a request allow a 7-copy order and a slice calling another slice save an order whose payment was declined, then prevent that with an ArchUnitNET slice test.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

Bookstore has a rule: an order may contain at most 5 copies of the same book. In post 02 we put this rule on the PlaceOrder slice's request as an attribute. Now the team writes the AddOrderItem slice, which adds a book to an existing order, and follows the same approach:

public sealed record PlaceOrderLine(int BookId, [Range(1, 5)] int Quantity);
using System.ComponentModel.DataAnnotations;
using Bookstore.Api.Domain;
using Bookstore.Api.Infrastructure;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Api.Features.Orders.AddOrderItem;

public sealed record AddOrderItemRequest(int BookId, [Range(1, 5)] int Quantity);

public sealed class AddOrderItemHandler(BookstoreDbContext db)
{
    public async Task<bool> HandleAsync(int orderId, AddOrderItemRequest request, CancellationToken ct)
    {
        Order? order = await db.Orders.Include(o => o.Items).SingleOrDefaultAsync(o => o.Id == orderId, ct);
        Book? book = await db.Books.FindAsync([request.BookId], ct);
        if (order is null || book is null)
            return false;

        if (order.Status != "Placed")
            throw new DomainException($"Order {order.Id} is {order.Status}.");

        OrderItem? existing = order.Items.Find(i => i.BookId == book.Id);
        if (existing is null)
            order.Items.Add(new OrderItem { BookId = book.Id, Quantity = request.Quantity, UnitPrice = book.Price });
        else
            existing.Quantity += request.Quantity;
        await db.SaveChangesAsync(ct);
        return true;
    }
}

Both requests accept a quantity between 1 and 5. The "only orders in Placed status can change" check is a copy of the line in CancelOrder. Guess: if Ada places an order with 3 copies of Dune and then adds 4 more copies of Dune to the same order, what happens?

Does the Rule Belong to the Request or to the Order?

POST /api/orders {"customerId":7,"lines":[{"bookId":1,"quantity":3}]}
HTTP 201 {"orderId":1,"total":37.5}
POST /api/orders/1/items {"bookId":1,"quantity":4}
HTTP 204
GET /api/orders/1
HTTP 200 {"id":1,"status":"Placed","lines":[{"bookId":1,"quantity":7}]}

The order now holds 7 copies of Dune. Each request was valid on its own; the rule, however, isn't a property of any request but of the order. The same hole exists in PlaceOrder itself: send the same book on two lines (3 and 4) and you get 201, with the order saved as two separate lines totalling 7 copies.

We mixed up two different concepts. Input validation asks whether a single request makes sense in itself: is the quantity positive, is the list empty. An invariant is a rule that must always hold for an object: an order can't contain more than 5 copies of a book, a cancelled order doesn't change. Spread an invariant across requests and every new slice has to write it again, and it only takes one to forget. That's why, in VSA (Vertical Slice Architecture), slices being independent of each other doesn't mean "every slice writes its own rules".

Moving the Rule into the Entity

In post 01 we saw Bogard's suggestion: a slice starts as a transaction script and is refactored toward the domain when code smells appear. A state check copied into two slices and a rule forgotten in one of them is exactly that code smell. The rules move into Order:

namespace Bookstore.Api.Domain;

public sealed class DomainException(string message) : Exception(message);

public enum OrderStatus { Placed, Paid, Cancelled }

public sealed class Order
{
    public const int MaxCopiesPerBook = 5;
    private readonly List<OrderItem> _items = [];

    private Order() { }

    public Order(int customerId, DateTime placedAtUtc)
    {
        CustomerId = customerId;
        PlacedAtUtc = placedAtUtc;
    }

    public int Id { get; private set; }
    public int CustomerId { get; private set; }
    public OrderStatus Status { get; private set; } = OrderStatus.Placed;
    public DateTime PlacedAtUtc { get; private set; }
    public IReadOnlyList<OrderItem> Items => _items;
    public decimal Total => _items.Sum(i => i.UnitPrice * i.Quantity);

    // The rule lives here, not in a request: it holds whichever slice calls it
    public void AddItem(int bookId, int quantity, decimal unitPrice)
    {
        EnsureStatus(OrderStatus.Placed);
        OrderItem? existing = _items.Find(i => i.BookId == bookId);
        int total = (existing?.Quantity ?? 0) + quantity;
        if (total > MaxCopiesPerBook)
            throw new DomainException($"At most {MaxCopiesPerBook} copies of book {bookId} per order.");

        if (existing is null)
            _items.Add(new OrderItem(bookId, quantity, unitPrice));
        else
            existing.Quantity = total;
    }

    public void MarkPaid()
    {
        EnsureStatus(OrderStatus.Placed);
        Status = OrderStatus.Paid;
    }

    public void Cancel()
    {
        EnsureStatus(OrderStatus.Placed);
        Status = OrderStatus.Cancelled;
    }

    private void EnsureStatus(OrderStatus expected)
    {
        if (Status != expected)
            throw new DomainException($"Order {Id} is {Status}.");
    }
}

The setters are private and the item list is exposed read-only; Entity Framework Core (EF Core) finds the _items backing field by convention, with no extra configuration. The handlers no longer know any rules; they only run the workflow:

public async Task<bool> HandleAsync(int orderId, AddOrderItemRequest request, CancellationToken ct)
{
    Order? order = await db.Orders.Include(o => o.Items).SingleOrDefaultAsync(o => o.Id == orderId, ct);
    Book? book = await db.Books.FindAsync([request.BookId], ct);
    if (order is null || book is null)
        return false;

    order.AddItem(book.Id, request.Quantity, book.Price);
    await db.SaveChangesAsync(ct);
    return true;
}

A domain rule violation should become the same HTTP (Hypertext Transfer Protocol) response in every slice; that, too, is a shared piece independent of any slice:

using Bookstore.Api.Domain;
using Microsoft.AspNetCore.Diagnostics;

namespace Bookstore.Api.Infrastructure;

// A shared cross-cutting concern: a domain rule violation becomes the same response in every slice
public sealed class DomainExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken ct)
    {
        if (exception is not DomainException)
            return false;
        context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = context,
            ProblemDetails = { Title = "Business rule violated", Detail = exception.Message, Status = 422 }
        });
    }
}

The same requests now:

POST /api/orders {"customerId":7,"lines":[{"bookId":1,"quantity":3}]}
HTTP 201 {"orderId":1,"total":37.5}
POST /api/orders/1/items {"bookId":1,"quantity":4}
HTTP 422 {"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Business rule violated","status":422,"detail":"At most 5 copies of book 1 per order."}
POST /api/orders/1/cancel
HTTP 204
POST /api/orders/1/items {"bookId":2,"quantity":1}
HTTP 422 {"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Business rule violated","status":422,"detail":"Order 1 is Cancelled."}
POST /api/orders {"customerId":7,"lines":[{"bookId":1,"quantity":3},{"bookId":1,"quantity":4}]}
HTTP 422 {"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Business rule violated","status":422,"detail":"At most 5 copies of book 1 per order."}

The [Range(1, 5)] on the requests stays where it is: it's still valid input validation, it just doesn't stand in for the invariant. This small model is a first step toward the domain model from Martin Fowler's PoEAA (Patterns of Enterprise Application Architecture, 2002). Designing Order as an aggregate responsible for its own consistency, value objects, and how to draw their boundaries are topics for the upcoming Domain-Driven Design series.

What to Share, What Not to Share

The refactoring above is an example of a general rule about what should be common across slices. What should be shared:

  • The domain model: entities, value objects, and their invariants. A rule is written once, in the object it belongs to.
  • Cross-cutting concerns: exception handling, the ProblemDetails format, validation infrastructure, logging, authorization policies. These are independent of a slice's workflow.
  • Infrastructure: a single DbContext and adapters for external systems (like IPaymentGateway).

Two things are deliberately missing from the list: repositories and service classes. Bogard's 2018 post says that these shared abstractions of a layered architecture become largely unnecessary with slices; when every slice uses DbContext directly and writes its own query, there's no need for a common "order data access" layer. If a query really is used with the same meaning in many slices ("the active customer's open orders", say), sharing it as a small extension method on IQueryable<Order> is far lighter than building a full repository layer.

What should not be shared is the slice's own shape: request and response types, queries, the workflow. Two read slices' queries may look alike today; one will change for the order list screen, the other for the customer service report. Merging them into one GetOrdersQuery ties two different reasons to change to one place. The same goes for "generic handlers": an abstraction like CrudHandler<TEntity> saves time until the first different need, then turns into a new shared file every slice adds an if to.

A practical way to make the call is the rule of three, attributed to Don Roberts and popularized by Fowler's "Refactoring": write similar code the first time, notice the repetition the second time, turn it into an abstraction the third time. It's a good measure of patience for incidental similarity (two queries happening to look alike). For invariants, though, there's no need to wait: in Hunt and Thomas's original sense, DRY (Don't Repeat Yourself) is about duplicated knowledge, not lines of code; "at most 5 copies of a book per order" is a piece of knowledge and its second copy is a source of bugs from day one. This is where Bogard's warning matters: VSA assumes the team sees this distinction and actually does the refactoring.

Pitfall: A Slice Calling a Slice

We need a Checkout slice that takes payment. With "there's already a slice that knows how to place an order" in mind, the first version calls it:

using Bookstore.Api.Features.Orders.PlaceOrder;
using Bookstore.Api.Infrastructure;

namespace Bookstore.Api.Features.Checkout;

public sealed record CheckoutRequest(int CustomerId, List<PlaceOrderLine> Lines, string CardToken);

// "Don't repeat yourself": call the slice that already knows how to place an order
public sealed class CheckoutHandler(PlaceOrderHandler placeOrder, IPaymentGateway payments)
{
    public async Task<PlaceOrderResponse?> HandleAsync(CheckoutRequest request, CancellationToken ct)
    {
        PlaceOrderResponse? placed = await placeOrder.HandleAsync(new PlaceOrderRequest(request.CustomerId, request.Lines), ct);
        if (placed is null)
            return null;

        if (!await payments.ChargeAsync(request.CustomerId, placed.Total, request.CardToken, ct))
            return null;
        return placed;
    }
}

A fake adapter stands in for the payment provider and declines the "declined" token. A declined payment:

POST /api/checkout {"customerId":8,"lines":[{"bookId":2,"quantity":2}],"cardToken":"declined"}
HTTP 402 {"title":"Payment declined","status":402}
GET /api/orders/2
HTTP 200 {"id":2,"status":"Placed","lines":[{"bookId":2,"quantity":2}]}

We told the customer "payment declined", yet the order sits in the database. The reason is that PlaceOrderHandler calls its own SaveChangesAsync: that slice draws its own transaction boundary and Checkout is left outside it. On a successful payment the order also stays Placed, because PlaceOrderHandler knows nothing about payment. On top of that, Checkout's request is now tied to PlaceOrderLine and its response to PlaceOrderResponse; adding a field to PlaceOrder's API (Application Programming Interface) now changes Checkout too. All the benefit of keeping slices independent was handed back with a single constructor parameter.

What should have been shared wasn't PlaceOrderHandler but the rules it uses. The corrected Checkout builds the order with the domain model itself, takes the payment, and saves with a single SaveChangesAsync:

public async Task<CheckoutResponse?> HandleAsync(CheckoutRequest request, CancellationToken ct)
{
    int[] ids = [.. request.Lines.Select(l => l.BookId).Distinct()];
    Dictionary<int, decimal> prices = await db.Books.Where(b => ids.Contains(b.Id)).ToDictionaryAsync(b => b.Id, b => b.Price, ct);

    // What is shared is not PlaceOrderHandler but Order's rules
    Order order = new(request.CustomerId, clock.GetUtcNow().UtcDateTime);
    foreach (CheckoutLine line in request.Lines)
        order.AddItem(line.BookId, line.Quantity, prices[line.BookId]);

    if (!await payments.ChargeAsync(request.CustomerId, order.Total, request.CardToken, ct))
        return null;

    order.MarkPaid();
    db.Orders.Add(order);
    await db.SaveChangesAsync(ct);
    return new CheckoutResponse(order.Id, order.Total);
}
POST /api/checkout {"customerId":8,"lines":[{"bookId":2,"quantity":2}],"cardToken":"declined"}
HTTP 402 {"title":"Payment declined","status":402}
GET /api/orders/2
HTTP 404
POST /api/checkout {"customerId":8,"lines":[{"bookId":2,"quantity":2}],"cardToken":"tok_ok"}
HTTP 201 {"orderId":2,"total":16.0}
GET /api/orders/2
HTTP 200 {"id":2,"status":"Paid","lines":[{"bookId":2,"quantity":2}]}

The two lines that load prices are the same as in PlaceOrder, and that's deliberate duplication: the two slices' queries are the same today, but tomorrow Checkout may want to take campaign prices into account. If one slice's job really is to react to another slice's result (send an email when an order is placed, say), the right tool is not a direct call but a domain event; the upcoming Messaging and Event-Driven Architecture series will cover it together with the outbox.

Protecting the Rule with a Test

Instead of leaving "slices don't depend on each other" to code review, you can turn it into a test. The slice rules of ArchUnitNET, which we used in the Clean, Onion, and Hexagonal Architecture series post "Testing and Protecting Clean Architecture: Core Tests, Adapter Tests, and Architecture Tests", are made for exactly this:

using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using ArchUnitNET.xUnitV3;
using static ArchUnitNET.Fluent.Slices.SliceRuleDefinition;

namespace Bookstore.Api.ArchitectureTests;

public sealed class SliceTests
{
    private static readonly Architecture Architecture =
        new ArchLoader().LoadAssemblies(typeof(Program).Assembly).Build();

    // Features.<Area>.<Slice> or Features.<Slice>: each slice sees only Domain and Infrastructure
    [Fact]
    public void Slices_do_not_depend_on_each_other() =>
        Slices().Matching("Bookstore.Api.Features.(**)").Should().NotDependOnEachOther().Check(Architecture);
}

The (**) pattern makes the full namespace under Features the slice identity: Orders.PlaceOrder, Checkout, and so on. With the faulty Checkout:

failed Bookstore.Api.ArchitectureTests.SliceTests.Slices_do_not_depend_on_each_other (88ms)
  ArchUnitNET.xUnitV3.FailedArchRuleException : "Slices matching "Bookstore.Api.Features.(**)" should not depend on each other" failed:
  	Checkout does depend on other slices:
  Checkout -> Orders.PlaceOrder
  	Bookstore.Api.Features.Checkout.CheckoutRequest -> Bookstore.Api.Features.Orders.PlaceOrder.PlaceOrderLine
  	Bookstore.Api.Features.Checkout.CheckoutHandler -> Bookstore.Api.Features.Orders.PlaceOrder.PlaceOrderHandler
  	Bookstore.Api.Features.Checkout.CheckoutHandler -> Bookstore.Api.Features.Orders.PlaceOrder.PlaceOrderRequest
  	Bookstore.Api.Features.Checkout.CheckoutHandler -> Bookstore.Api.Features.Orders.PlaceOrder.PlaceOrderResponse
  	Bookstore.Api.Features.Checkout.CheckoutEndpoint -> Bookstore.Api.Features.Orders.PlaceOrder.PlaceOrderResponse

The test caught not only the handler call but also the shared request and response types. With the corrected version the test passes. Writing sender.Send(new PlaceOrderCommand(...)) with one of the mediators from post 03 is the same violation: the dependency moves from the handler to the command type, but the test sees that too.

Don't Let the Shared Folder Become the New Utils

In most VSA projects a Shared or Common folder appears over time and, left unchecked, takes the place of Helpers and Utils in a layered architecture: a place every slice adds something to, nobody owns, and where every change breaks some unexpected slice. A few rules prevent that:

  • Don't put business rules in shared code; business rules go to Domain, into the entity itself.
  • Make a piece shared once it has at least two real consumers, not because "we'll need it later".
  • Name folders by concept, not by technical category: not Shared/Extensions but Infrastructure/Payments, Infrastructure/ProblemDetails.
  • Changing a shared piece means running the tests of every slice that uses it; pay that cost knowingly.

Common Pitfalls

A shared response DTO (Data Transfer Object). A single OrderResponse returned by every order slice is the layered OrderDto from post 01 coming back. The slice that wants the most fields bloats everyone's response, and renaming a field affects every client.

Postponing the refactoring. "Let's copy it into two slices for now" usually lasts, for invariants, until the next bug. The 7-copy order above appeared the day the second slice was written.

Moving everything into the entity. The domain model is for invariants; a screen's query, a report's grouping logic, or an email's text don't belong to Order. Read slices often work through projections without loading the entity at all, and they should stay that way.

Wiring cross-slice reactions with direct calls. If "restock the books when an order is cancelled" is needed, CancelOrder calling the stock slice's handler is the same mistake as the Checkout above. Either both jobs are one slice's responsibility in the same transaction, or one tells the other through an event.

A Small Experiment

★☆☆ In the transaction script version, send a PlaceOrder request with the same book on two lines and check how many copies the order is saved with. Then repeat the same request against the domain model version.

★★☆ Add a rule to Order: "the order total can't exceed 1000". How many files did you touch? How many handlers would you have had to change to add the same rule to the transaction script version?

★★★ Suppose books must be restocked after CancelOrder. First write the version where CancelOrder calls a RestockBooks handler directly and read what the ArchUnitNET test says. Then do the same with a domain event collected by Order and a handler that processes it after SaveChangesAsync.

In this post we moved the rules into the domain and separated slices from each other with a test. In the next post we talk about how to test the slices themselves: an integration test per slice, a real SQL (Structured Query Language) Server database with Testcontainers, isolation between tests, and pure unit tests for domain rules.