Skip to content
StarLive demo →
ConceptChecked against main · 31 Oct 20267 min read

Vertical slices

Inside a module, code is grouped by use case, not by layer. Each slice folder holds its endpoint, command, handler and the C# 14 extension block that changes the aggregate.

Slice
One folder per use case
Named
Verb first: RegisterCustomer/
Domain logic
An extension(Customer) block in the slice
Shared code
The feature folder and its Database/

A feature folder holds the aggregate and the code several slices share. Each use case gets its own subfolder, named after the action.

This is CRM’s Customers/ feature, trimmed:

  • DirectoryCustomers/
    • Customer.cs # the aggregate: properties only
    • CustomerId.cs
    • CustomerDto.cs # read model for the query slices
    • CustomerPermissions.cs
    • CustomerRehydrationSource.cs # CRM’s contract implementation
    • DirectoryDatabase/ # shared by several slices
      • CustomerTypeConfiguration.cs # EF mapping
      • CustomerFilters.cs # query-filter names
      • CustomerQueries.cs # EF: LoadAsync
      • CustomersRepository.cs # Dapper reads
      • CustomerRecord.cs
    • DirectoryRegisterCustomer/
      • RegisterCustomer.cs # extension(Customer): Register
      • RegisterCustomerCommand.cs
      • RegisterCustomerCommandHandler.cs
      • RegisterCustomerEndpoint.cs # POST /api/v1/customers
      • RegisterCustomerRequest.cs
      • RegisterCustomerResponse.cs
    • DirectoryUpdateCustomer/
      • …
    • DirectoryDeleteCustomer/
      • …
    • DirectoryGetCustomers/
      • …
    • DirectoryGetCustomerById/
      • …
    • DirectoryGetNewCustomerStats/
      • …
    • …

One request runs through one folder:

RegisterCustomerEndpointmaps the request with ToCommand()
RegisterCustomerCommandHandlerreached through bus.InvokeAsync
Customer.Registerthe extension in RegisterCustomer.cs
Figure 1. Everything specific to registering a customer sits in RegisterCustomer/.
What Where Why there
Endpoint, request, command, handler, response The slice Only this use case needs them
The domain operation (Customer.Register) The slice, in a file named after it The business rule changes with the use case
A helper only one slice uses (NewCustomerStatsCalculator) That slice, GetNewCustomerStats/ One user, so it stays local
The aggregate and its ID (Customer, CustomerId) The feature folder Every slice uses them
Read models and permissions (CustomerDto, CustomerPermissions) The feature folder Several slices use them
EF mapping, filter names, EF and Dapper queries Database/ in the feature folder Shared infrastructure

Tests follow the same shape: AppointMe.Crm.Tests/Customers/RegisterCustomer/RegisterCustomerTests.cs tests the RegisterCustomer/ slice.

Folders and files start with the action, so a module’s folder list reads as the list of things it can do.

Kind of slice Verbs Examples
Business intent on the module’s own aggregate Domain verbs RegisterCustomer, ScheduleAppointment, RescheduleAppointment, CancelAppointment
A projection synced from another module CRUD verbs CreateAttendee, UpdateAttendee, DeleteAttendee (Booking’s copy of CRM customers)
A query Get… GetCustomers, GetCustomerById, GetNewCustomerStats
Sync and background work Task verbs ReconcileAttendees, SeedDemoCustomers, TopUpDemoCustomers

Files inside a slice take the slice name plus a role: RegisterCustomerCommand, RegisterCustomerCommandHandler, RegisterCustomerEndpoint, RegisterCustomerRequest and RegisterCustomerResponse. Queries use …Query and …QueryHandler. A slice only has the files it needs: DeleteCustomer/ has no request, because the ID comes from the route.

The convention has a few exceptions. GetCustomers/ holds GetCustomerQuery (singular), and some Organizations folders are named for a screen rather than an action (Me/, Companies/Onboarding/).

The aggregate is a plain record. The code that creates or changes it is a C# 14 extension block that lives in the slice that needs it.

src/CRM/AppointMe.Crm/Customers/Customer.csView source ↗
public sealed record Customer : AggregateRoot
{
public required CustomerId Id { get; init; }
public required CompanyId CompanyId { get; init; }
public required PersonName Name { get; set; }
public required DateOfBirth? DateOfBirth { get; set; }
public required Gender? Gender { get; set; }
public required Email? Email { get; set; }
public required DateTimeOffset RegistrationDate { get; init; }
public required bool IsDeleted { get; set; }
}

A factory is a static member in an extension(Customer) block. It builds the aggregate and raises the event:

src/CRM/AppointMe.Crm/Customers/RegisterCustomer/RegisterCustomer.csView source ↗
extension(Customer)
{
public static Customer Register(CompanyId companyId, PersonName name, DateOfBirth? dateOfBirth,
Gender? gender, Email? email, DateTimeOffset registrationDate)
{
var customer = new Customer
{
Id = new CustomerId(NewId()),
// …
IsDeleted = false
};
customer.Raise(new CustomerRegisteredEvent(
customer.Id.Value, companyId.Value,
name.FirstName, name.LastName, dateOfBirth?.Value, email?.Value));
return customer;
}
}

A mutator is an instance member in an extension(Customer customer) block:

src/CRM/AppointMe.Crm/Customers/DeleteCustomer/DeleteCustomer.csView source ↗
extension(Customer customer)
{
public void Delete()
{
customer.IsDeleted = true;
customer.Raise(new CustomerDeletedEvent(customer.Id.Value, customer.CompanyId.Value));
}
}

At the call site they read like members of Customer. The handler sits in the same namespace as the extension, so it needs no using:

src/CRM/AppointMe.Crm/Customers/DeleteCustomer/DeleteCustomerCommandHandler.csView source ↗
var customer = await dbContext.Customers.LoadAsync(new CustomerId(command.CustomerId), cancellationToken);
customer.Delete();
await dbContext.SaveChangesAsync(cancellationToken);

LoadAsync is itself an extension, on IQueryable<Customer>, in Customers/Database/CustomerQueries.cs, because several slices load a customer by ID.

An event handler lives in the slice whose operation it calls, not in a shared Handlers/ folder.

Booking keeps a projection of CRM customers. Its projection slices hold only the domain operation, since no HTTP endpoint creates an attendee. The handlers for CRM’s events sit with the synchronization code they call:

  • DirectoryAttendees/
    • Attendee.cs
    • AttendeeId.cs
    • DirectoryCreateAttendee/
      • CreateAttendee.cs # extension(Attendee): Create
    • DirectoryUpdateAttendee/
      • UpdateAttendee.cs
    • DirectoryDeleteAttendee/
      • DeleteAttendee.cs
    • DirectoryReconcileAttendees/
      • AttendeeSynchronizer.cs # turns a CustomerSnapshot into an upsert
      • UpsertAttendee.cs # calls Attendee.Create or attendee.Update
      • CustomerRegisteredEventHandler.cs
      • CustomerUpdatedEventHandler.cs
      • CustomerDeletedEventHandler.cs
      • ReconcileAttendeesCommandHandler.cs # the daily full re-sync
      • …
    • DirectoryDatabase/
      • …

CustomerRegisteredEventHandler and CustomerUpdatedEventHandler both call AttendeeSynchronizer.Apply, the same code the daily reconciliation runs, so all three live in ReconcileAttendees/. CustomerDeletedEventHandler calls attendee.Delete() from DeleteAttendee/ but sits with the other sync handlers, which shows the rule is a convention, applied with judgment. In Identity, EmployeeInvitedHandler handles Organizations’ EmployeeInvited event from Users/InviteUser/.

Step Where What
1. Name it <Feature>/<Verb><Noun>/ A domain verb for business intent, Get… for a query, a CRUD verb for a projection
2. Write the operation <Verb><Noun>.cs in the slice extension(Aggregate) for a factory, extension(Aggregate instance) for a change. Raise the event there.
3. Add the message and handler …Command.cs, …CommandHandler.cs The handler checks the permission, calls the operation and saves
4. Expose it …Endpoint.cs, …Request.cs An IEndpoint that maps the request to the command and calls bus.InvokeAsync
5. Test it AppointMe.<Module>.Tests/<Feature>/<Slice>/ Tests mirror the slice folder
6. Share only when needed The feature folder or its Database/ Move code up when a second slice needs it, not before

The step-by-step version, with the code for each file, is Add an endpoint. To build a whole feature end to end, follow Build your first feature.

Symptom Cause Fix
CS1061 on customer.Delete() outside its slice Each slice has its own namespace, and the extension isn’t imported Add the slice’s using, as Booking’s CustomerDeletedEventHandler does with AppointMe.Booking.Attendees.DeleteAttendee
A change is saved but no event is raised A handler set a property directly (customer.Name = …) instead of calling the operation Change state only through the slice’s operation. The setters are public, so code review has to catch this.
Two slices need the same SQL Each query handler was written in isolation Put the query in the feature’s repository (CustomersRepository) and call it from both
A helper is used by two slices It started in one slice Move it to the feature folder or Database/
Why slices instead of layers?2 min · optional

A layered layout puts a use case’s request in one folder, its handler in another and its data access in a third. A slice puts it in one folder, so a change, a review and a deletion stay in one place. Coupling between slices is explicit: when two slices need the same code, it moves up into the feature folder where everyone can see it.

The cost is some deliberate duplication. RegisterCustomerRequest and RegisterCustomerCommand have the same fields today, so the HTTP shape and the message can change independently later. There’s more on this trade-off in vs Clean Architecture.

Why extension blocks instead of methods on the aggregate?1 min · optional

A Customer class with every operation as a method grows with each use case and becomes the file every change touches. With C# 14 extension blocks, Customer.Register(...) and customer.Delete() still read like members, but each one lives in its slice, and deleting the slice deletes the operation.

The cost: an extension can only use the aggregate’s accessible members, so every property an operation changes has a public setter. Keeping state changes inside the operations is a convention, not something the compiler enforces.