Domain-Driven Design with Onion Architecture in .NET 9: Building Scalable Microservices

In modern software development, creating systems that are maintainable, testable, and adaptable to change is crucial. Our platform showcases a real-world implementation of Domain-Driven Design (DDD) combined with Onion Architecture principles, all built on .NET 9. This architectural approach has allowed us to develop a robust microservices ecosystem that delivers both business agility and technical excellence.
.NET has evolved dramatically over the past decade. With .NET 9 and ASP.NET Core 9, Microsoft’s flagship platform has doubled down on performance, cloud-native microservices, and developer productivity. But as the tools get more powerful, the architectures we choose to build with them matter more than ever.
If you’re a developer or architect trying to keep your codebases clean, maintainable, and adaptable in a world of microservices, buzzwords like Domain-Driven Design (DDD), Onion Architecture, and Clean Architecture are probably familiar. But how do these ideas actually fit together? How do you put them into practice in a real ASP.NET Core 9 solution? And how do you ensure your system stays robust as it scales?
In this post, we’ll break all that down. We’ll look at how Clean Architecture, Onion Architecture, and Domain-Driven Design complement each other — and how you can implement these principles in an ASP.NET Core 9 microservices solution that’s easy to reason about, test, and evolve.
By the end, you’ll have a practical roadmap to help you structure your next enterprise-ready .NET 9 application.
Why Architecture Still Matters
The .NET 9 ecosystem is rich with features: minimal APIs, powerful performance optimizations, improved cloud-native tooling, and first-class support for containerized deployments and orchestration.
But even the best framework can’t save a poorly structured application.
Modern business requirements change fast. You might need to pivot your domain logic, adapt to new integrations, split services apart, or rewrite pieces without taking the whole system down.
If your code is tangled up — if it’s just “controller spaghetti” directly tied to your database models — you’ll hit walls fast. That’s where proper architecture comes in.
Clean Architecture and Onion Architecture are design patterns that help you organize your solution in a way that:
- Puts your business logic at the center, protected from external concerns.
- Makes dependencies flow in one direction — inward.
- Keeps infrastructure details (like databases, web APIs, message queues) on the outer layers, easy to swap or replace.
- Encourages strong boundaries and separation of concerns.
When you pair this with Domain-Driven Design, you also ensure that your code reflects the real business domain — the real problems you’re solving — instead of just being a CRUD wrapper around tables.
The Microservices Landscape
The platform consists of multiple interdependent yet decoupled microservices, each responsible for a specific business domain:
// From AppHost/Program.cs - Our microservices orchestration
var token = builder.AddProject<Projects.Networks_Token>("tokenservice");
var buddy = builder.AddProject<Projects.Networks_BuddyService>("buddyservice")
.WithReference(cache).WaitFor(cache)
.WithReference(token).WaitFor(token)
.WithReference(seq).WaitFor(seq);
var profile = builder.AddProject<Projects.Networks_ProfileService>("profileservice")
.WithReference(cache).WaitFor(cache)
.WithReference(token).WaitFor(token)
.WithReference(seq).WaitFor(seq);
// Additional services...
This structure allows us to scale, deploy, and maintain each service independently. Let's explore how DDD and Onion Architecture principles guide the design of each service.
Domain-Driven Design: Aligning Code with Business Reality
Domain-Driven Design puts the focus on the core domain and domain logic, mapping business concepts to code structures. In the solution, each microservice represents a bounded context - a logical boundary around a specific domain model.
Ubiquitous Language
A cornerstone of DDD is establishing a common language shared between developers and domain experts. In our codebase, this is evident in how our domain models reflect business concepts:
// Domain models that reflect our business language
public class ProfileDomainModel
{
public Guid Id { get; set; }
public Guid Guid { get; set; }
public string UserName { get; set; }
public string NormalizedUserName { get; set; }
public string Email { get; set; }
public Site Site { get; set; }
public string Location { get; set; }
public int? LocationId { get; set; }
public bool IsOnline { get; set; }
public DateTime? LastOnline { get; set; }
// Additional properties...
}
This domain model captures the essence of a user profile in our system, using terms that business stakeholders understand.
Bounded Contexts
Each microservice maintains its own bounded context. For example, the ProfileService manages everything related to user profiles, while the BuddyService handles relationships between users. These services communicate through well-defined interfaces, preserving context boundaries.
Onion Architecture: Dependencies Point Inward
Onion Architecture (also known as Clean Architecture or Hexagonal Architecture) organizes code in concentric layers, with domain models at the center. Dependencies always point inward, ensuring that the core business logic remains independent of infrastructure concerns.
The Domain Layer: At the Core
The domain layer represents the business concepts and rules:
// Domain interfaces define the contract without implementation details
public interface IIntegrationData
{
Task<ProfileDomainModel> FetchProfileAsync(Guid userGuid, Site site);
Task<List<ProfileDomainModel>> FetchProfilListAsync(List<Guid> userGuids, Site site);
Task<List<HoneyPotDomainModel>> GetHoneyPotAsync(Guid userGuid, Site site);
Task MarkUserAsLoggedOffAsync(Guid userGuid, Site site);
Task<ProfileDomainModel> UpsertDefaultProfileAsync(string userName, string email, Site site);
}
These interfaces define what the system needs from a business perspective, without specifying how these operations are implemented.
The Data Layer: Infrastructure Concerns
The data layer implements the domain interfaces, connecting to databases and other external systems:
public class IntegrationData : IIntegrationData
{
private readonly ProfileDbContext _context;
public IntegrationData(ProfileDbContext context)
{
_context = context;
}
public Task<ProfileDomainModel> FetchProfileAsync(Guid userGuid, Site site)
{
return _context.Profiles
.Where(up => up.Guid == userGuid && up.Site == site)
.FirstOrDefaultAsync();
}
// Other implementation methods...
}
Here, the data layer depends on domain interfaces but implements them using Entity Framework Core. The domain layer remains completely unaware of these implementation details.
The Business Layer: Application Services
The business layer orchestrates operations and enforces business rules:
public class IntegrationBusiness : IIntegrationBusiness
{
private readonly IIntegrationData _data;
public IntegrationBusiness(IIntegrationData data)
{
_data = data;
}
public async Task<ProfileServiceModel> FetchProfileAsync(Guid userGuid, Site site)
{
var domain = await _data.FetchProfileAsync(userGuid, site);
if (domain == null)
{
throw new ProfileNotFoundException($"Profile not found for userGuid: {userGuid} on site: {site}");
}
return domain.MapToServiceModel();
}
// Other business methods...
}
The business layer depends on abstractions from both the domain and data layers, but never on concrete implementations. This ensures flexibility and testability.
The Facade Pattern: Simplifying Interactions
One key architectural choice in solution is the extensive use of the Facade pattern. The Facade sits between the business layer and the API layer, providing a simplified interface for clients and handling cross-cutting concerns:
public class IntegrationFacade : IIntegrationFacade
{
private readonly IIntegrationBusiness _business;
private readonly CacheAside _cache;
public IntegrationFacade(IIntegrationBusiness business, IDistributedCache cache)
{
_business = business;
_cache = new CacheAside(cache);
}
public async Task<Result<ProfileServiceModel>> FetchProfileAsync(Guid userGuid, Site site)
{
try
{
if (userGuid == Guid.Empty || !Enum.IsDefined(typeof(Site), site))
return Result<ProfileServiceModel>.Failure(Error.InvalidInput,
new List<string> { "Invalid userGuid or site." });
string cacheKey = $"profile_{userGuid}_{site}";
var data = await _cache.FetchFromCache<ProfileServiceModel>(cacheKey, async () =>
{
return await _business.FetchProfileAsync(userGuid, site);
}, new DistributedCacheEntryOptions() { AbsoluteExpiration = DateTime.UtcNow.AddHours(24) });
return Result<ProfileServiceModel>.Success(data);
}
catch (ProfileNotFoundException)
{
return Result<ProfileServiceModel>.Failure(Error.NotFound,
new List<string> { "Profile not found." });
}
}
// Other facade methods...
}
The Facade pattern offers several benefits:
- Centralized Input Validation: The Facade layer validates inputs before passing them to the business layer.
- Standardized Error Handling: Exceptions are caught and converted into consistent response formats.
- Cross-Cutting Concerns: Caching, logging, and other aspects are managed here.
- Simplified API: Controllers interact with a clean, consistent interface.
Dependency Injection: Wiring It All Together
.NET 9's built-in dependency injection container manages the instantiation and lifetime of our components:
void AddScopes(WebApplicationBuilder builder)
{
builder.Services.AddScoped<IIntegrationFacade, IntegrationFacade>();
builder.Services.AddScoped<IIntegrationBusiness, IntegrationBusiness>();
builder.Services.AddScoped<IIntegrationData, IntegrationData>();
builder.Services.AddScoped<IProfileFacade, ProfileFacade>();
builder.Services.AddScoped<IProfileBusiness, ProfileBusiness>();
builder.Services.AddScoped<IProfileData, ProfileData>();
builder.Services.AddScoped<IHoneyPotFacade, HoneyPotFacade>();
builder.Services.AddScoped<IHoneyPotBusiness, HoneyPotBusiness>();
builder.Services.AddScoped<IHoneyPotData, HoneyPotData>();
builder.Services.AddScoped<IServiceBusSender, ServiceBusSender>();
// Additional registrations...
}
This configuration allows for flexible composition while maintaining the dependency inversion principle. Higher-level components depend on abstractions, not concrete implementations.
API Layer: Thin Controllers
With our layered architecture in place, controllers remain remarkably thin:
[ApiController]
[Route("api/buddy/v{v:apiVersion}/[controller]")]
[ApiVersion(1)]
public class IntegrationController : ControllerBase
{
private readonly IIntegrationFacade _facade;
public IntegrationController(IIntegrationFacade facade)
{
_facade = facade;
}
[HttpGet("{userGuid:guid}/{site:int}")]
public async Task<IActionResult> GetBuddies(Guid userGuid, int site)
{
var result = await _facade.GetBuddies(userGuid, (Site)site);
return result.Match();
}
// Other endpoint methods...
}
The controllers simply map HTTP requests to facade calls and convert the results to HTTP responses, without any business logic.
Inter-Service Communication
In a microservices architecture, services need to communicate. We implement two primary methods:
HTTP Clients with Refit
For synchronous communication, we use typed HTTP clients with Refit:
void AddHttpClients(WebApplicationBuilder builder)
{
builder.Services.AddRefitClient<IProfileHttpClient>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(
builder.Environment.EnvironmentName != "LocalDevelopment"
? builder.Configuration["profileurl"]
: "https+http://profileservice"))
.AddHttpMessageHandler(() => new AuthHeaderHandler(builder.Services.BuildServiceProvider()));
// Additional HTTP clients...
}
These strongly-typed clients ensure type safety when communicating between services.
Service Bus for Asynchronous Communication
For event-based communication, we leverage Azure Service Bus:
builder.Services.AddSingleton<ServiceBusClient>(sp =>
new ServiceBusClient(builder.Configuration.GetConnectionString("servicebus")));
This facilitates loosely coupled, asynchronous interactions between services.
Infrastructure as Code with .NET Aspire
Our solution leverages .NET Aspire to define and orchestrate the entire microservices ecosystem:
var builder = DistributedApplication.CreateBuilder(args);
// Infrastructure components
var cache = builder.AddRedis("cache")
.WithLifetime(ContainerLifetime.Persistent)
.WithHostPort(6379)
.WithRedisInsight()
.WithRedisCommander();
// Service definitions with dependencies
var profile = builder.AddProject<Projects.Networks_ProfileService>("profileservice")
.WithReference(cache).WaitFor(cache)
.WithReference(token).WaitFor(token)
.WithReference(seq).WaitFor(seq);
This declarative approach ensures consistent deployment and proper dependency management.
Cross-Cutting Concerns
Several aspects span multiple services and layers:
Caching
The Facade layer implements a cache-aside pattern to improve performance:
var data = await _cache.FetchFromCache<BuddyListServiceModel>(cacheKey, async () =>
{
return await _business.GetBuddies(userGuid, site);
}, new DistributedCacheEntryOptions() { AbsoluteExpiration = DateTime.UtcNow.AddHours(24) });
Authentication and Authorization
Security is managed consistently across services:
void AddAuthentication(WebApplicationBuilder builder)
{
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
var key = Encoding.UTF8.GetBytes(
builder.Configuration["Jwt:Key"];
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
});
}
Logging and Monitoring
OpenTelemetry integration provides comprehensive observability:
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation();
});
// Configure exporters...
return builder;
}
Benefits of Our Architectural Approach
This DDD + Onion Architecture approach delivers numerous advantages:
1. Testability
Since business logic depends only on abstractions, unit testing becomes straightforward:
[TestMethod]
public async Task AssociateUserAsync_Valid_CallsDataAndProfile()
{
// Arrange
var model = new AssociateViewModel { UserName = userName, Level = level };
_userDataMock.Setup(d => d.AssociateUserAsync(userName, level, site))
.ReturnsAsync(email);
_profileMock.Setup(p => p.UpsertDefaultProfile(userName, email, (int)site))
.ReturnsAsync(Result<ProfileServiceModel>.Success(
new ProfileServiceModel { Guid = Guid.NewGuid() }));
// Act
await _business.AssociateUserAsync(model, site);
// Assert
_userDataMock.Verify(d => d.AssociateUserAsync(userName, level, site), Times.Once);
_profileMock.Verify(p => p.UpsertDefaultProfile(userName, email, (int)site), Times.Once);
}
2. Maintainability
The clear separation of concerns makes the codebase more maintainable. Developers can understand and modify specific parts of the system without needing to comprehend the entire system.
3. Flexibility
Implementation details can change without affecting the core business logic:
- Switching from Cosmos DB to SQL Server would only affect the Data layer
- Changing from REST APIs to gRPC would only impact the API layer
- Updating caching strategies would be isolated to the Facade layer
4. Scalability
Each microservice can scale independently based on its specific load characteristics, optimizing resource usage.
Challenges and Solutions
While the benefits are significant, this architecture isn't without challenges:
1. Development Complexity
Challenge: More layers and abstractions mean more code to write initially.
Solution: We established clear templates and conventions to streamline development and maintain consistency.
2. Distributed Data Management
Challenge: With each service owning its data, ensuring consistency across services is complex.
Solution: We employ event-driven patterns for eventual consistency and carefully design service boundaries to minimize cross-service transactions.
3. Performance Overhead
Challenge: Additional layers and service boundaries can introduce performance overhead.
Solution: Strategic caching in the Facade layer, connection pooling, and efficient serialization mitigate these concerns.
Conclusion: Enabling Business Agility Through Technical Excellence
Our DDD + Onion Architecture approach has enabled our client to grow and evolve rapidly while maintaining code quality and developer productivity. By ensuring that our code structure reflects business realities and maintains clean separation of concerns, we've created a system that's both technically sound and business-aligned.
The combination of Domain-Driven Design, Onion Architecture, and the Facade pattern has proven particularly powerful. It allows domain experts to contribute meaningfully to the design, helps developers understand the business context, and maintains clean boundaries between different aspects of the system.
As the platform continues to grow, this architectural foundation will enable us to adapt to changing requirements, scale individual components as needed, and incorporate new technologies without destabilizing the entire system. The result is a microservices ecosystem that delivers business value consistently while remaining technically maintainable and evolvable.
In an era where software must continuously adapt to changing business needs, investing in solid architectural principles isn't just a technical concern—it's a strategic business advantage. Our experience with this project demonstrates that Domain-Driven Design, Onion Architecture, and clean separation of concerns provide a robust foundation for building complex, scalable, and maintainable microservices systems in .NET 9.
Comments ()