Using DDD, Hexagonal Architecture, Modular Monoliths, and Vertical Slices in the Same .NET Solution

Modern .NET architecture discussions often become more confusing than they need to be because teams treat every pattern as if it competes with every other pattern. DDD, modular monoliths, vertical slices, and hexagonal architecture do not solve the same problem. They sit at different levels of the design.
A modular monolith answers the system-level question: how do we split a single deployable application into meaningful business modules?
DDD answers the modelling question: how do we represent the business rules, language, behaviours, and consistency boundaries inside those modules?
Vertical slice architecture answers the feature-organisation question: how do we keep each use case close to the endpoint, request, response, validation, and handler code that implements it?
Hexagonal architecture answers the dependency question: how do we keep business decisions away from infrastructure details such as EF Core, queues, file storage, HTTP clients, and third-party APIs?
Used together, they can give you a strong architecture without forcing you into microservices too early. Used badly, they create a maze of folders, abstractions, and ceremony that slows every feature down.
This post shows the useful version.
The example uses .NET 10, C# 14, minimal APIs, EF Core, module contracts, vertical slices, and a small DDD model. The domain is a conference booking system, not an order system, because the shape is familiar but still has enough rules to justify the design.
The short version
This is the mental model I use.
Modular monolith = boundaries between business capabilities
DDD = business model inside a boundary
Vertical slices = use cases inside a boundary
Hexagonal architecture = dependency direction around business behaviour
That gives you a structure like this.
The host is still one application. The deployment is still simple. The modules are separate enough that you can reason about them, test them, and stop one module leaking all over the others.
When this is a good match
This combination is a good match when your application has real business behaviour. I mean behaviour, not just data entry. You probably have statuses, approvals, capacity rules, pricing rules, permissions, lifecycle transitions, audit requirements, or workflows that cross more than one business area.
A booking platform fits. So does a healthcare workflow, subscription platform, finance approval system, training platform, case management system, or document-processing workflow.
It is not a good match for a tiny CRUD admin app. If the whole application is just five screens over five tables, you do not need aggregates, module contracts, ports, adapters, domain events, and a folder structure that looks more impressive than the problem it solves.
Use the weight when the business earns it, dont over engineer because it looks cool.
The architecture
The solution has one deployable API project. Inside that project, the code is split by module. Each module owns its own features, domain model, application ports, infrastructure adapters, and public contracts.
/src
/ConferenceBooking.Api
ConferenceBooking.Api.csproj
Program.cs
appsettings.json
/BuildingBlocks
Error.cs
Result.cs
ProblemDetailsMapper.cs
/Modules
/Conferences
ConferencesModule.cs
/Contracts
IConferenceAvailability.cs
ConferenceAvailabilitySnapshot.cs
SessionAvailabilitySnapshot.cs
/Infrastructure
InMemoryConferenceAvailability.cs
/Registrations
RegistrationsModule.cs
/RegisterAttendee
RegisterAttendeeEndpoint.cs
RegisterAttendeeCommand.cs
RegisterAttendeeHandler.cs
RegisterAttendeeRequest.cs
RegisterAttendeeResponse.cs
/Application
IRegistrationRepository.cs
/Domain
Attendee.cs
ConferenceId.cs
ConferenceTicket.cs
EmailAddress.cs
Money.cs
RegisteredSession.cs
Registration.cs
RegistrationErrors.cs
RegistrationId.cs
RegistrationSnapshot.cs
SessionId.cs
SessionSeat.cs
/Infrastructure
EfRegistrationRepository.cs
RegistrationDbContext.cs
RegistrationRecord.cs
There are two important rules here.
The first rule is that modules do not share database tables as a communication mechanism. The Registrations module does not query the Conferences module's tables. It asks the Conferences module through a public contract.
The second rule is that the domain does not know EF Core exists. EF Core is an adapter. The repository interface is a port. That keeps the business model clean.
This is hexagonal architecture without the theatre. The domain is not wrapped in five layers. It is simply protected from infrastructure.
I was introduced to this pattern around five years ago at Flipdish, a colleague, Adam Bieganski, introduced me to the idea. My first reaction was that it felt like a strange way to structure code. But once he explained the dependency direction, ports, and adapters, the value became obvious. The clever part is not the shape of the diagram. It is the way the architecture keeps business behaviour at the centre and pushes infrastructure decisions to the edge.
The request flow
A vertical slice owns the use case. For RegisterAttendee, the slice owns the request, command, endpoint, response, and handler.
Notice what does not happen. The endpoint does not contain business logic. The handler does not directly use EF Core. The aggregate does not call another module. The other module does not hand out its internal entities.
That separation is the point.
Project file
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
</ItemGroup>
</Project>
appsettings.json
{
"ConnectionStrings": {
"Registrations": "Data Source=registrations.db"
}
}
Program.cs
using ConferenceBooking.Api.Modules.Conferences;
using ConferenceBooking.Api.Modules.Registrations;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddConferencesModule();
builder.Services.AddRegistrationsModule(builder.Configuration);
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseExceptionHandler();
app.MapRegistrationsModule();
await app.InitialiseRegistrationsDatabaseAsync();
await app.RunAsync();
public partial class Program;
The API host knows how to compose modules. It does not know the details of a registration aggregate, a conference availability lookup, or an EF Core repository. That keeps the host boring, which is exactly what you want.
Building blocks
These are intentionally small. The goal is not to build a framework inside the application. The goal is to give handlers and endpoints a consistent way to return errors.
BuildingBlocks/Error.cs
namespace ConferenceBooking.Api.BuildingBlocks;
internal enum ErrorKind
{
Validation,
NotFound,
Conflict,
RuleBroken
}
internal readonly record struct Error(
string Code,
string Description,
ErrorKind Kind)
{
public static readonly Error None = new(
Code: string.Empty,
Description: string.Empty,
Kind: ErrorKind.Validation);
}
BuildingBlocks/Result.cs
namespace ConferenceBooking.Api.BuildingBlocks;
internal readonly record struct Result
{
private Result(bool isSuccess, Error error)
{
IsSuccess = isSuccess;
Error = error;
}
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
}
internal readonly record struct Result<T>
{
private readonly T? _value;
private Result(T value)
{
IsSuccess = true;
Error = Error.None;
_value = value;
}
private Result(Error error)
{
IsSuccess = false;
Error = error;
_value = default;
}
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
public T Value => IsSuccess
? _value!
: throw new InvalidOperationException("Cannot access the value of a failed result.");
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(Error error) => new(error);
}
BuildingBlocks/ProblemDetailsMapper.cs
using Microsoft.AspNetCore.Mvc;
namespace ConferenceBooking.Api.BuildingBlocks;
internal static class ProblemDetailsMapper
{
public static ProblemDetails ToProblemDetails(Error error)
{
var status = error.Kind switch
{
ErrorKind.NotFound => StatusCodes.Status404NotFound,
ErrorKind.Conflict => StatusCodes.Status409Conflict,
ErrorKind.Validation => StatusCodes.Status400BadRequest,
ErrorKind.RuleBroken => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status400BadRequest
};
return new ProblemDetails
{
Title = error.Code,
Detail = error.Description,
Status = status,
Type = $"https://httpstatuses.com/{status}"
};
}
}
Conferences module
The Conferences module exposes only what another module needs. It does not expose its domain model. It does not expose its database context. It does not allow the Registrations module to reach inside and take whatever it wants.
That is what a public contract is for.
Modules/Conferences/ConferencesModule.cs
using ConferenceBooking.Api.Modules.Conferences.Contracts;
using ConferenceBooking.Api.Modules.Conferences.Infrastructure;
namespace ConferenceBooking.Api.Modules.Conferences;
internal static class ConferencesModule
{
public static IServiceCollection AddConferencesModule(this IServiceCollection services)
{
services.AddSingleton<IConferenceAvailability, InMemoryConferenceAvailability>();
return services;
}
}
Modules/Conferences/
Contracts/IConferenceAvailability.cs
namespace ConferenceBooking.Api.Modules.Conferences.Contracts;
internal interface IConferenceAvailability
{
Task<ConferenceAvailabilitySnapshot?> GetConferenceAsync(
Guid conferenceId,
CancellationToken stopToken);
Task<SessionAvailabilitySnapshot?> GetSessionAsync(
Guid sessionId,
CancellationToken stopToken);
}
Modules/Conferences/
Contracts/ConferenceAvailabilitySnapshot.cs
namespace ConferenceBooking.Api.Modules.Conferences.Contracts;
internal sealed record ConferenceAvailabilitySnapshot(
Guid ConferenceId,
string Name,
int Capacity,
int ReservedPlaces,
decimal TicketPriceAmount,
string TicketPriceCurrency);
Modules/Conferences/
Contracts/SessionAvailabilitySnapshot.cs
namespace ConferenceBooking.Api.Modules.Conferences.Contracts;
internal sealed record SessionAvailabilitySnapshot(
Guid SessionId,
Guid ConferenceId,
string Title,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
int Capacity,
int ReservedPlaces);
Modules/Conferences/
Infrastructure/InMemoryConferenceAvailability.cs
using ConferenceBooking.Api.Modules.Conferences.Contracts;
namespace ConferenceBooking.Api.Modules.Conferences.Infrastructure;
internal sealed class InMemoryConferenceAvailability : IConferenceAvailability
{
private static readonly Guid ConferenceId = Guid.Parse("018f8dc6-6a72-7a93-ae7f-1e872f6eaa01");
private static readonly Guid ModularArchitectureSessionId = Guid.Parse("018f8dc6-6a72-7a93-ae7f-1e872f6eaa02");
private static readonly Guid TestingSessionId = Guid.Parse("018f8dc6-6a72-7a93-ae7f-1e872f6eaa03");
private static readonly Guid ClashingSessionId = Guid.Parse("018f8dc6-6a72-7a93-ae7f-1e872f6eaa04");
private readonly Dictionary<Guid, ConferenceAvailabilitySnapshot> _conferences = new()
{
[ConferenceId] = new ConferenceAvailabilitySnapshot(
ConferenceId: ConferenceId,
Name: "Practical Architecture Summit",
Capacity: 300,
ReservedPlaces: 184,
TicketPriceAmount: 495m,
TicketPriceCurrency: "EUR")
};
private readonly Dictionary<Guid, SessionAvailabilitySnapshot> _sessions = new()
{
[ModularArchitectureSessionId] = new SessionAvailabilitySnapshot(
SessionId: ModularArchitectureSessionId,
ConferenceId: ConferenceId,
Title: "Modular monoliths without the mess",
StartsAtUtc: new DateTimeOffset(2026, 10, 7, 9, 30, 0, TimeSpan.Zero),
EndsAtUtc: new DateTimeOffset(2026, 10, 7, 10, 30, 0, TimeSpan.Zero),
Capacity: 120,
ReservedPlaces: 82),
[TestingSessionId] = new SessionAvailabilitySnapshot(
SessionId: TestingSessionId,
ConferenceId: ConferenceId,
Title: "Testing domain-heavy .NET systems",
StartsAtUtc: new DateTimeOffset(2026, 10, 7, 11, 0, 0, TimeSpan.Zero),
EndsAtUtc: new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero),
Capacity: 80,
ReservedPlaces: 52),
[ClashingSessionId] = new SessionAvailabilitySnapshot(
SessionId: ClashingSessionId,
ConferenceId: ConferenceId,
Title: "Refactoring legacy layers into slices",
StartsAtUtc: new DateTimeOffset(2026, 10, 7, 9, 45, 0, TimeSpan.Zero),
EndsAtUtc: new DateTimeOffset(2026, 10, 7, 10, 45, 0, TimeSpan.Zero),
Capacity: 100,
ReservedPlaces: 74)
};
public Task<ConferenceAvailabilitySnapshot?> GetConferenceAsync(
Guid conferenceId,
CancellationToken stopToken)
{
_conferences.TryGetValue(conferenceId, out var conference);
return Task.FromResult(conference);
}
public Task<SessionAvailabilitySnapshot?> GetSessionAsync(
Guid sessionId,
CancellationToken stopToken)
{
_sessions.TryGetValue(sessionId, out var session);
return Task.FromResult(session);
}
}
This adapter is in memory only to keep the example focused. In a real system, the contract could be backed by a read model, another module's query service, a cached projection, or eventually an HTTP call if that module is extracted into a service.
The important part is the dependency shape. The caller depends on the public contract, not on another module's internals.
Registrations module
The Registrations module contains the use case we care about. It owns the Registration aggregate and the persistence port for registrations.
Modules/Registrations/RegistrationsModule.cs
using ConferenceBooking.Api.Modules.Registrations.Application;
using ConferenceBooking.Api.Modules.Registrations.Infrastructure;
using ConferenceBooking.Api.Modules.Registrations.RegisterAttendee;
using Microsoft.EntityFrameworkCore;
namespace ConferenceBooking.Api.Modules.Registrations;
internal static class RegistrationsModule
{
public static IServiceCollection AddRegistrationsModule(
this IServiceCollection services,
IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("Registrations")
?? "Data Source=registrations.db";
services.AddDbContext<RegistrationDbContext>(options =>
{
options.UseSqlite(connectionString);
});
services.AddScoped<IRegistrationRepository, EfRegistrationRepository>();
services.AddScoped<RegisterAttendeeHandler>();
return services;
}
public static IEndpointRouteBuilder MapRegistrationsModule(this IEndpointRouteBuilder app)
{
var group = app
.MapGroup("/registrations")
.WithTags("Registrations");
group.MapPost("/", RegisterAttendeeEndpoint.Handle)
.WithName("RegisterAttendee")
.WithSummary("Registers an attendee for a conference")
.WithDescription("Creates a registration and reserves the selected sessions when the business rules allow it.")
.Produces<RegisterAttendeeResponse>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status409Conflict);
return app;
}
public static async Task InitialiseRegistrationsDatabaseAsync(this WebApplication app)
{
await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<RegistrationDbContext>();
await db.Database.EnsureCreatedAsync();
}
}
EnsureCreatedAsync keeps the sample runnable. For a production app, use migrations and run them through your deployment process rather than creating the schema from the application at startup.
The vertical slice
The endpoint should be thin. It maps transport concerns to a command, delegates the use case to the handler, then maps the result to an HTTP response.
The handler owns orchestration. It loads external facts, creates value objects, calls the aggregate, and persists through a port.
Modules/Registrations/
RegisterAttendee/RegisterAttendeeRequest.cs
namespace ConferenceBooking.Api.Modules.Registrations.RegisterAttendee;
internal sealed record RegisterAttendeeRequest(
Guid ConferenceId,
string AttendeeName,
string AttendeeEmail,
IReadOnlyCollection<Guid>? SessionIds);
Modules/Registrations/
RegisterAttendee/RegisterAttendeeCommand.cs
namespace ConferenceBooking.Api.Modules.Registrations.RegisterAttendee;
internal sealed record RegisterAttendeeCommand(
Guid ConferenceId,
string AttendeeName,
string AttendeeEmail,
IReadOnlyCollection<Guid> SessionIds);
Modules/Registrations/
RegisterAttendee/RegisterAttendeeResponse.cs
namespace ConferenceBooking.Api.Modules.Registrations.RegisterAttendee;
internal sealed record RegisterAttendeeResponse(
Guid RegistrationId,
Guid ConferenceId,
string AttendeeEmail,
decimal PriceAmount,
string PriceCurrency,
IReadOnlyCollection<Guid> SessionIds);
Modules/Registrations/
RegisterAttendee/RegisterAttendeeEndpoint.cs
using ConferenceBooking.Api.BuildingBlocks;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
namespace ConferenceBooking.Api.Modules.Registrations.RegisterAttendee;
internal static class RegisterAttendeeEndpoint
{
public static async Task<Results<
Created<RegisterAttendeeResponse>,
BadRequest<ProblemDetails>,
NotFound<ProblemDetails>,
Conflict<ProblemDetails>>> Handle(
RegisterAttendeeRequest request,
RegisterAttendeeHandler handler,
CancellationToken stopToken)
{
var command = new RegisterAttendeeCommand(
ConferenceId: request.ConferenceId,
AttendeeName: request.AttendeeName,
AttendeeEmail: request.AttendeeEmail,
SessionIds: request.SessionIds ?? []);
var result = await handler.Handle(command, stopToken);
if (result.IsSuccess)
{
var response = result.Value;
return TypedResults.Created(
$"/registrations/{response.RegistrationId}",
response);
}
var problem = ProblemDetailsMapper.ToProblemDetails(result.Error);
return result.Error.Kind switch
{
ErrorKind.NotFound => TypedResults.NotFound(problem),
ErrorKind.Conflict => TypedResults.Conflict(problem),
_ => TypedResults.BadRequest(problem)
};
}
}
This is still minimal API code, but it is not dumping application logic directly into Program.cs. Minimal APIs are not the same thing as minimal architecture. You can keep the endpoint style light without turning your API host into a junk drawer.
Modules/Registrations/
RegisterAttendee/RegisterAttendeeHandler.cs
using ConferenceBooking.Api.BuildingBlocks;
using ConferenceBooking.Api.Modules.Conferences.Contracts;
using ConferenceBooking.Api.Modules.Registrations.Application;
using ConferenceBooking.Api.Modules.Registrations.Domain;
namespace ConferenceBooking.Api.Modules.Registrations.RegisterAttendee;
internal sealed class RegisterAttendeeHandler(
IConferenceAvailability conferenceAvailability,
IRegistrationRepository registrations,
TimeProvider clock)
{
public async Task<Result<RegisterAttendeeResponse>> Handle(
RegisterAttendeeCommand command,
CancellationToken stopToken)
{
var conferenceId = new ConferenceId(command.ConferenceId);
var attendeeResult = Attendee.Create(
name: command.AttendeeName,
email: command.AttendeeEmail);
if (attendeeResult.IsFailure)
{
return Result<RegisterAttendeeResponse>.Failure(attendeeResult.Error);
}
var conference = await conferenceAvailability.GetConferenceAsync(
command.ConferenceId,
stopToken);
if (conference is null)
{
return Result<RegisterAttendeeResponse>.Failure(RegistrationErrors.ConferenceNotFound(command.ConferenceId));
}
var priceResult = Money.Create(
amount: conference.TicketPriceAmount,
currency: conference.TicketPriceCurrency);
if (priceResult.IsFailure)
{
return Result<RegisterAttendeeResponse>.Failure(priceResult.Error);
}
var ticket = new ConferenceTicket(
ConferenceId: conferenceId,
Name: conference.Name,
Capacity: conference.Capacity,
ReservedPlaces: conference.ReservedPlaces,
Price: priceResult.Value);
var alreadyRegistered = await registrations.ExistsForAttendeeAsync(
conferenceId,
attendeeResult.Value.Email,
stopToken);
if (alreadyRegistered)
{
return Result<RegisterAttendeeResponse>.Failure(
RegistrationErrors.AttendeeAlreadyRegistered(attendeeResult.Value.Email.Value));
}
var registrationResult = Registration.Create(
ticket,
attendeeResult.Value,
clock.GetUtcNow());
if (registrationResult.IsFailure)
{
return Result<RegisterAttendeeResponse>.Failure(registrationResult.Error);
}
var registration = registrationResult.Value;
foreach (var sessionId in command.SessionIds.Distinct())
{
var addSessionResult = await AddSessionAsync(
registration,
conferenceId,
sessionId,
stopToken);
if (addSessionResult.IsFailure)
{
return Result<RegisterAttendeeResponse>.Failure(addSessionResult.Error);
}
}
await registrations.AddAsync(registration, stopToken);
return Result<RegisterAttendeeResponse>.Success(new RegisterAttendeeResponse(
RegistrationId: registration.Id.Value,
ConferenceId: registration.ConferenceId.Value,
AttendeeEmail: registration.Attendee.Email.Value,
PriceAmount: registration.Price.Amount,
PriceCurrency: registration.Price.Currency,
SessionIds: registration.Sessions.Select(x => x.SessionId.Value).ToArray()));
}
private async Task<Result> AddSessionAsync(
Registration registration,
ConferenceId conferenceId,
Guid sessionId,
CancellationToken stopToken)
{
var session = await conferenceAvailability.GetSessionAsync(sessionId, stopToken);
if (session is null)
{
return Result.Failure(RegistrationErrors.SessionNotFound(sessionId));
}
if (session.ConferenceId != conferenceId.Value)
{
return Result.Failure(RegistrationErrors.SessionBelongsToDifferentConference(sessionId));
}
var seat = new SessionSeat(
SessionId: new SessionId(session.SessionId),
Title: session.Title,
StartsAtUtc: session.StartsAtUtc,
EndsAtUtc: session.EndsAtUtc,
Capacity: session.Capacity,
ReservedPlaces: session.ReservedPlaces);
return registration.AddSession(seat);
}
}
The handler uses a public contract from another module, but the aggregate does not. That distinction matters. Aggregates should enforce business decisions. They should not become service locators that call databases, APIs, or other modules.
The domain model
This is where DDD earns its place. The registration rules are not spread across endpoints, EF queries, and UI assumptions. They sit in the aggregate and value objects.
The rules in this example are small but realistic. An attendee needs a valid name and email. A conference must have capacity. A registration cannot contain duplicate sessions. A selected session must have capacity. An attendee cannot select two sessions that clash.
Modules/Registrations/Domain/RegistrationId.cs
namespace ConferenceBooking.Api.Modules.Registrations.Domain;
internal readonly record struct RegistrationId(Guid Value)
{
public static RegistrationId New() => new(Guid.CreateVersion7());
}
Modules/Registrations/Domain/ConferenceId.cs
namespace ConferenceBooking.Api.Modules.Registrations.Domain;
internal readonly record struct ConferenceId(Guid Value);
Modules/Registrations/Domain/SessionId.cs
namespace ConferenceBooking.Api.Modules.Registrations.Domain;
internal readonly record struct SessionId(Guid Value);
Modules/Registrations/Domain/EmailAddress.cs
using System.Net.Mail;
using ConferenceBooking.Api.BuildingBlocks;
namespace ConferenceBooking.Api.Modules.Registrations.Domain;
internal sealed record EmailAddress
{
private EmailAddress(string value)
{
Value = value;
}
public string Value { get; }
public static Result<EmailAddress> Create(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return Result<EmailAddress>.Failure(RegistrationErrors.EmailRequired());
}
try
{
var address = new MailAddress(value.Trim());
return Result<EmailAddress>.Success(new EmailAddress(address.Address.ToLowerInvariant()));
}
catch (FormatException)
{
return Result<EmailAddress>.Failure(RegistrationErrors.EmailInvalid(value));
}
}
public override string ToString() => Value;
}
Modules/Registrations/Domain/Attendee.cs
using ConferenceBooking.Api.BuildingBlocks;
namespace ConferenceBooking.Api.Modules.Registrations.Domain;
internal sealed record Attendee
{
private Attendee(string name, EmailAddress email)
{
Name = name;
Email = email;
}
public string Name { get; }
public EmailAddress Email { get; }
public static Result<Attendee> Create(string? name, string? email)
{
if (string.IsNullOrWhiteSpace(name))
{
return Result<Attendee>.Failure(RegistrationErrors.AttendeeNameRequired());
}
var emailResult = EmailAddress.Create(email);
if (emailResult.IsFailure)
{
return Result<Attendee>.Failure(emailResult.Error);
}
return Result<Attendee>.Success(new Attendee(
name.Trim(),
emailResult.Value));
}
}
Modules/Registrations/Domain/Money.cs
using ConferenceBooking.Api.BuildingBlocks;
namespace ConferenceBooking.Api.Modules.Registrations.Domain;
internal readonly record struct Money(decimal Amount, string Currency)
{
public static Result<Money> Create(decimal amount, string currency)
{
if (amount < 0)
{
return Result<Money>.Failure(RegistrationErrors.PriceCannotBeNegative());
}
if (string.IsNullOrWhiteSpace(currency))
{
return Result<Money>.Failure(RegistrationErrors.CurrencyRequired());
}
return Result<Money>.Success(new Money(
Amount: decimal.Round(amount, 2),
Currency: currency.Trim().ToUpperInvariant()));
}
}
Modules/Registrations/Domain/ConferenceTicket.cs
namespace ConferenceBooking.Api.Modules.Registrations.Domain;
internal sealed record ConferenceTicket(
ConferenceId ConferenceId,
string Name,
int Capacity,
int ReservedPlaces,
Money Price)
{
public bool HasCapacity => ReservedPlaces < Capacity;
}
Modules/Registrations/Domain/SessionSeat.cs
namespace ConferenceBooking.Api.Modules.Registrations.Domain;
internal sealed record SessionSeat(
SessionId SessionId,
string Title,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
int Capacity,
int ReservedPlaces)
{
public bool HasCapacity => ReservedPlaces < Capacity;
public bool ClashesWith(SessionSeat other) =>
StartsAtUtc < other.EndsAtUtc && other.StartsAtUtc < EndsAtUtc;
}
Modules/Registrations/Domain/RegisteredSession.cs
namespace ConferenceBooking.Api.Modules.Registrations.Domain;
internal sealed record RegisteredSession(
SessionId SessionId,
string Title,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc);
Modules/Registrations/Domain/RegistrationSnapshot.cs
namespace ConferenceBooking.Api.Modules.Registrations.Domain;
internal sealed record RegistrationSnapshot(
Guid Id,
Guid ConferenceId,
string AttendeeName,
string AttendeeEmail,
decimal PriceAmount,
string PriceCurrency,
DateTimeOffset CreatedAtUtc,
IReadOnlyCollection<RegisteredSessionSnapshot> Sessions);
internal sealed record RegisteredSessionSnapshot(
Guid SessionId,
string Title,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc);
Modules/Registrations/Domain/RegistrationErrors.cs
using ConferenceBooking.Api.BuildingBlocks;
namespace ConferenceBooking.Api.Modules.Registrations.Domain;
internal static class RegistrationErrors
{
public static Error ConferenceNotFound(Guid conferenceId) => new(
Code: "Registration.ConferenceNotFound",
Description: $"Conference '{conferenceId}' was not found.",
Kind: ErrorKind.NotFound);
public static Error SessionNotFound(Guid sessionId) => new(
Code: "Registration.SessionNotFound",
Description: $"Session '{sessionId}' was not found.",
Kind: ErrorKind.NotFound);
public static Error SessionBelongsToDifferentConference(Guid sessionId) => new(
Code: "Registration.SessionBelongsToDifferentConference",
Description: $"Session '{sessionId}' does not belong to the selected conference.",
Kind: ErrorKind.Validation);
public static Error AttendeeAlreadyRegistered(string email) => new(
Code: "Registration.AttendeeAlreadyRegistered",
Description: $"Attendee '{email}' is already registered for this conference.",
Kind: ErrorKind.Conflict);
public static Error ConferenceFull() => new(
Code: "Registration.ConferenceFull",
Description: "The conference has no remaining capacity.",
Kind: ErrorKind.Conflict);
public static Error SessionFull(string title) => new(
Code: "Registration.SessionFull",
Description: $"Session '{title}' has no remaining capacity.",
Kind: ErrorKind.Conflict);
public static Error DuplicateSession(string title) => new(
Code: "Registration.DuplicateSession",
Description: $"Session '{title}' has already been selected.",
Kind: ErrorKind.Validation);
public static Error SessionTimeClash(string firstTitle, string secondTitle) => new(
Code: "Registration.SessionTimeClash",
Description: $"Session '{firstTitle}' clashes with session '{secondTitle}'.",
Kind: ErrorKind.Validation);
public static Error AttendeeNameRequired() => new(
Code: "Registration.AttendeeNameRequired",
Description: "Attendee name is required.",
Kind: ErrorKind.Validation);
public static Error EmailRequired() => new(
Code: "Registration.EmailRequired",
Description: "Attendee email is required.",
Kind: ErrorKind.Validation);
public static Error EmailInvalid(string? value) => new(
Code: "Registration.EmailInvalid",
Description: $"'{value}' is not a valid email address.",
Kind: ErrorKind.Validation);
public static Error PriceCannotBeNegative() => new(
Code: "Registration.PriceCannotBeNegative",
Description: "The ticket price cannot be negative.",
Kind: ErrorKind.Validation);
public static Error CurrencyRequired() => new(
Code: "Registration.CurrencyRequired",
Description: "A currency is required.",
Kind: ErrorKind.Validation);
}
Modules/Registrations/Domain/Registration.cs
using ConferenceBooking.Api.BuildingBlocks;
namespace ConferenceBooking.Api.Modules.Registrations.Domain;
internal sealed class Registration
{
private readonly List<RegisteredSession> _sessions = [];
private Registration(
RegistrationId id,
ConferenceId conferenceId,
Attendee attendee,
Money price,
DateTimeOffset createdAtUtc,
IEnumerable<RegisteredSession>? sessions = null)
{
Id = id;
ConferenceId = conferenceId;
Attendee = attendee;
Price = price;
CreatedAtUtc = createdAtUtc;
if (sessions is not null)
{
_sessions.AddRange(sessions);
}
}
public RegistrationId Id { get; }
public ConferenceId ConferenceId { get; }
public Attendee Attendee { get; }
public Money Price { get; }
public DateTimeOffset CreatedAtUtc { get; }
public IReadOnlyCollection<RegisteredSession> Sessions => _sessions;
public static Result<Registration> Create(
ConferenceTicket ticket,
Attendee attendee,
DateTimeOffset createdAtUtc)
{
if (!ticket.HasCapacity)
{
return Result<Registration>.Failure(RegistrationErrors.ConferenceFull());
}
var registration = new Registration(
id: RegistrationId.New(),
conferenceId: ticket.ConferenceId,
attendee: attendee,
price: ticket.Price,
createdAtUtc: createdAtUtc);
return Result<Registration>.Success(registration);
}
public static Registration Restore(
RegistrationId id,
ConferenceId conferenceId,
Attendee attendee,
Money price,
DateTimeOffset createdAtUtc,
IEnumerable<RegisteredSession> sessions)
{
return new Registration(
id,
conferenceId,
attendee,
price,
createdAtUtc,
sessions);
}
public Result AddSession(SessionSeat seat)
{
if (!seat.HasCapacity)
{
return Result.Failure(RegistrationErrors.SessionFull(seat.Title));
}
if (_sessions.Any(x => x.SessionId == seat.SessionId))
{
return Result.Failure(RegistrationErrors.DuplicateSession(seat.Title));
}
var clashingSession = _sessions.FirstOrDefault(x =>
seat.StartsAtUtc < x.EndsAtUtc && x.StartsAtUtc < seat.EndsAtUtc);
if (clashingSession is not null)
{
return Result.Failure(RegistrationErrors.SessionTimeClash(
firstTitle: seat.Title,
secondTitle: clashingSession.Title));
}
_sessions.Add(new RegisteredSession(
SessionId: seat.SessionId,
Title: seat.Title,
StartsAtUtc: seat.StartsAtUtc,
EndsAtUtc: seat.EndsAtUtc));
return Result.Success();
}
public RegistrationSnapshot Snapshot()
{
return new RegistrationSnapshot(
Id: Id.Value,
ConferenceId: ConferenceId.Value,
AttendeeName: Attendee.Name,
AttendeeEmail: Attendee.Email.Value,
PriceAmount: Price.Amount,
PriceCurrency: Price.Currency,
CreatedAtUtc: CreatedAtUtc,
Sessions: _sessions
.Select(x => new RegisteredSessionSnapshot(
SessionId: x.SessionId.Value,
Title: x.Title,
StartsAtUtc: x.StartsAtUtc,
EndsAtUtc: x.EndsAtUtc))
.ToArray());
}
}
This is the part many Developers miss. The aggregate does not exist to make the code look more object-oriented. It exists because there are rules that must stay true together.
If the only rule were "insert a row into Registrations", this aggregate would be overkill. Here, it is useful because the registration has a consistency boundary. The selected sessions must be valid as a set.
The application port
The handler depends on an interface. The EF implementation sits behind that interface.
That is the hexagonal part in practical terms.
Modules/Registrations/
Application/IRegistrationRepository.cs
using ConferenceBooking.Api.Modules.Registrations.Domain;
namespace ConferenceBooking.Api.Modules.Registrations.Application;
internal interface IRegistrationRepository
{
Task<bool> ExistsForAttendeeAsync(
ConferenceId conferenceId,
EmailAddress email,
CancellationToken stopToken);
Task AddAsync(
Registration registration,
CancellationToken stopToken);
}
You can argue about whether this interface belongs under Application, Domain, or Ports. I usually put it near the application layer because the use case owns the need for persistence. The key point is not the folder name. The key point is the dependency direction.
Infrastructure adapter
The repository stores a persistence model rather than trying to make EF Core map the aggregate directly. That is not the only valid approach, but it is a clean one when you want the domain model to stay independent.
The persistence model belongs to the database adapter, not to the business model.
Modules/Registrations/
Infrastructure/RegistrationRecord.cs
namespace ConferenceBooking.Api.Modules.Registrations.Infrastructure;
internal sealed class RegistrationRecord
{
public Guid Id { get; set; }
public Guid ConferenceId { get; set; }
public string AttendeeName { get; set; } = string.Empty;
public string AttendeeEmail { get; set; } = string.Empty;
public decimal PriceAmount { get; set; }
public string PriceCurrency { get; set; } = string.Empty;
public DateTimeOffset CreatedAtUtc { get; set; }
public string SessionsJson { get; set; } = "[]";
}
Modules/Registrations/
Infrastructure/RegistrationDbContext.cs
using Microsoft.EntityFrameworkCore;
namespace ConferenceBooking.Api.Modules.Registrations.Infrastructure;
internal sealed class RegistrationDbContext(DbContextOptions<RegistrationDbContext> options) : DbContext(options)
{
public DbSet<RegistrationRecord> Registrations => Set<RegistrationRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var registration = modelBuilder.Entity<RegistrationRecord>();
registration.ToTable("Registrations");
registration.HasKey(x => x.Id);
registration.Property(x => x.ConferenceId)
.IsRequired();
registration.Property(x => x.AttendeeName)
.HasMaxLength(200)
.IsRequired();
registration.Property(x => x.AttendeeEmail)
.HasMaxLength(320)
.IsRequired();
registration.Property(x => x.PriceAmount)
.HasPrecision(18, 2)
.IsRequired();
registration.Property(x => x.PriceCurrency)
.HasMaxLength(3)
.IsRequired();
registration.Property(x => x.CreatedAtUtc)
.IsRequired();
registration.Property(x => x.SessionsJson)
.IsRequired();
registration.HasIndex(x => new
{
x.ConferenceId,
x.AttendeeEmail
}).IsUnique();
}
}
Modules/Registrations/
Infrastructure/EfRegistrationRepository.cs
using System.Text.Json;
using ConferenceBooking.Api.Modules.Registrations.Application;
using ConferenceBooking.Api.Modules.Registrations.Domain;
using Microsoft.EntityFrameworkCore;
namespace ConferenceBooking.Api.Modules.Registrations.Infrastructure;
internal sealed class EfRegistrationRepository(RegistrationDbContext dbContext) : IRegistrationRepository
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public Task<bool> ExistsForAttendeeAsync(
ConferenceId conferenceId,
EmailAddress email,
CancellationToken stopToken)
{
return dbContext.Registrations.AnyAsync(
x => x.ConferenceId == conferenceId.Value &&
x.AttendeeEmail == email.Value,
stopToken);
}
public async Task AddAsync(
Registration registration,
CancellationToken stopToken)
{
var snapshot = registration.Snapshot();
var record = new RegistrationRecord
{
Id = snapshot.Id,
ConferenceId = snapshot.ConferenceId,
AttendeeName = snapshot.AttendeeName,
AttendeeEmail = snapshot.AttendeeEmail,
PriceAmount = snapshot.PriceAmount,
PriceCurrency = snapshot.PriceCurrency,
CreatedAtUtc = snapshot.CreatedAtUtc,
SessionsJson = JsonSerializer.Serialize(snapshot.Sessions, JsonOptions)
};
dbContext.Registrations.Add(record);
await dbContext.SaveChangesAsync(stopToken);
}
}
For this use case, the repository only needs ExistsForAttendeeAsync and AddAsync. Do not create a generic repository just because the word repository appears in a DDD book. The port should describe what the use case needs.
Run it
Create the project and add the packages.
dotnet new web -n ConferenceBooking.Api
cd ConferenceBooking.Api
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.0
dotnet add package Microsoft.EntityFrameworkCore.Design --version 10.0.0
Then add the files shown above and run the app.
dotnet run
Send a request.
curl -X POST https://localhost:5001/registrations \
-H "Content-Type: application/json" \
-d '{
"conferenceId": "018f8dc6-6a72-7a93-ae7f-1e872f6eaa01",
"attendeeName": "Ava Byrne",
"attendeeEmail": "ava@example.com",
"sessionIds": [
"018f8dc6-6a72-7a93-ae7f-1e872f6eaa02",
"018f8dc6-6a72-7a93-ae7f-1e872f6eaa03"
]
}'
You should get a 201 Created response.
Now try two sessions that overlap.
curl -X POST https://localhost:5001/registrations \
-H "Content-Type: application/json" \
-d '{
"conferenceId": "018f8dc6-6a72-7a93-ae7f-1e872f6eaa01",
"attendeeName": "Ben Murphy",
"attendeeEmail": "ben@example.com",
"sessionIds": [
"018f8dc6-6a72-7a93-ae7f-1e872f6eaa02",
"018f8dc6-6a72-7a93-ae7f-1e872f6eaa04"
]
}'
You should get a 400 Bad Request with a Registration.SessionTimeClash problem.
The useful thing here is not the HTTP status code. The useful thing is where the decision lives. The clash rule lives in the aggregate. The endpoint just reports the result.
Why not put everything in the vertical slice?
You can put everything in the vertical slice for simple features. That is often the right choice. A lookup endpoint does not need an aggregate. A basic settings screen does not need a domain model. A simple query can use EF Core directly from a handler if it does not cross a business boundary.
The danger is using vertical slices as an excuse to scatter business rules everywhere. When every handler owns its own version of the rules, the system becomes inconsistent. One endpoint checks capacity. Another forgets. One endpoint validates overlapping sessions. Another does not. One endpoint knows what a duplicate registration means. Another only finds out when a database unique index fails.
Vertical slices organise use cases. DDD protects business rules. They do different jobs.
Why not full clean architecture?
You can use clean architecture, but many .NET solutions turn it into layer architecture by habit. Every feature gets a command, handler, validator, mapper, service, repository, DTO, domain event, and response model whether the feature needs them or not.
Thats not discipline. Thats ceremony.
In this style, a feature can stay small until it earns more structure. A query endpoint can be a single file. A complex command can use an aggregate. A module can have one database adapter. Another module can use an HTTP adapter. The architecture bends around the actual problem.
Thats why the combination works.
Where the public contracts belong
A module contract should be small. It should expose capabilities, not internals.
Good contract:
internal interface IConferenceAvailability
{
Task<ConferenceAvailabilitySnapshot?> GetConferenceAsync(
Guid conferenceId,
CancellationToken stopToken);
Task<SessionAvailabilitySnapshot?> GetSessionAsync(
Guid sessionId,
CancellationToken stopToken);
}
Bad contract:
internal interface IConferenceDatabase
{
IQueryable<ConferenceEntity> Conferences { get; }
IQueryable<SessionEntity> Sessions { get; }
}
The first contract protects the module boundary. The second contract deletes it.
A public contract should not let another module build arbitrary queries over your data. It should answer a business question that the other module is allowed to ask.
How this grows
This architecture gives you room to grow without forcing microservices early.
You can add a Payments module later. It can depend on a public contract from Registrations, such as IRegistrationPricing. It should not query the registration tables directly.
You can add domain events inside a module when something important happens. You can keep those events in-process while the application is a monolith. If you later split a module out, some of those events may become integration events.
You can add module-owned read models for queries. Not every query needs an aggregate. Reads and writes have different needs, and forcing them through the same object model often makes both worse.
You can eventually move a module out of process if the business, scaling, or team boundary justifies it. The point is that the module boundary already exists before you make that expensive move.
The architecture does not make extraction free. Nothing does. But it makes extraction less chaotic because the dependency shape is already honest.
The trade-offs
This style has costs.
You write more code than a direct CRUD endpoint. You need developers who understand boundaries. You need discipline around module contracts. You need to stop shared helpers becoming a dumping ground. You need to avoid turning every feature into a ceremony-heavy architecture diagram.
I wrote previously about ways to enforce architecture rules, its a good way to help a team learn to be disciplined.
The payoff is control. You get a system that can grow without becoming one giant application service with a thousand dependencies. You keep deployment simple while the domain is still changing. You keep business rules close to the language of the business. You keep infrastructure replaceable where it actually matters.
That is a good trade when the domain is real.
It is a bad trade when the problem is small.
The decision rule
Use this combination when the system has meaningful business rules and multiple business areas, but you do not yet need the operational cost of microservices.
Keep the rule simple.
Use modules for business boundaries.
Use vertical slices for use cases.
Use DDD where rules need protection.
Use hexagonal ports where infrastructure must not leak in.
Do not force DDD into every endpoint. Do not create ports for things that will never change. Do not create contracts that expose another module's database. Do not split into microservices just because the code has modules.
The best version of this architecture is not the most abstract version. It is the version where each pattern has a job and stops when that job is done.





