Clean Architecture in .NET: Skalierbare Projektstruktur
# Clean Architecture in .NET: Skalierbare Projektstruktur aufbauen
Clean Architecture hilft, grosse .NET-Systeme langfristig wartbar zu halten. Das Kernprinzip: Geschaeftslogik darf nicht von Framework- oder Infrastrukturdetails abhaengen. Abhaengigkeiten zeigen immer nach innen, zum Domain-Kern, niemals nach aussen.
In Enterprise-.NET-Projekten, die ich als Architekt begleitet habe, war dieser Ansatz konsequent der entscheidende Unterschied zwischen Codebasen, die gut altern, und solchen, die nach ein bis zwei Jahren unter ihrem eigenen Gewicht zusammenbrechen. Es geht dabei nicht um Dogmatismus, sondern darum, dem Team ein gemeinsames mentales Modell zu geben, wo welcher Code hingehoert.
Projektstruktur
Eine gut organisierte Solution macht die Architektur auf einen Blick sichtbar. Hier ist die Ordner- und Dateistruktur, die ich empfehle:
src/
├── MyApp.Domain/
│ ├── Entities/
│ │ ├── Order.cs
│ │ ├── OrderItem.cs
│ │ └── Customer.cs
│ ├── ValueObjects/
│ │ ├── Money.cs
│ │ └── Address.cs
│ ├── Enums/
│ │ └── OrderStatus.cs
│ ├── Events/
│ │ └── OrderPlacedEvent.cs
│ ├── Exceptions/
│ │ └── InsufficientStockException.cs
│ └── MyApp.Domain.csproj
│
├── MyApp.Application/
│ ├── Common/
│ │ ├── Interfaces/
│ │ │ ├── IOrderRepository.cs
│ │ │ ├── IPaymentGateway.cs
│ │ │ └── IUnitOfWork.cs
│ │ ├── Behaviours/
│ │ │ ├── ValidationBehaviour.cs
│ │ │ └── LoggingBehaviour.cs
│ │ └── Mappings/
│ │ └── OrderMappingProfile.cs
│ ├── Orders/
│ │ ├── Commands/
│ │ │ ├── PlaceOrder/
│ │ │ │ ├── PlaceOrderCommand.cs
│ │ │ │ ├── PlaceOrderCommandHandler.cs
│ │ │ │ └── PlaceOrderCommandValidator.cs
│ │ │ └── CancelOrder/
│ │ │ ├── CancelOrderCommand.cs
│ │ │ └── CancelOrderCommandHandler.cs
│ │ └── Queries/
│ │ └── GetOrderById/
│ │ ├── GetOrderByIdQuery.cs
│ │ ├── GetOrderByIdQueryHandler.cs
│ │ └── OrderDto.cs
│ └── MyApp.Application.csproj
│
├── MyApp.Infrastructure/
│ ├── Persistence/
│ │ ├── AppDbContext.cs
│ │ ├── Configurations/
│ │ │ └── OrderConfiguration.cs
│ │ └── Repositories/
│ │ └── OrderRepository.cs
│ ├── Services/
│ │ └── StripePaymentGateway.cs
│ ├── DependencyInjection.cs
│ └── MyApp.Infrastructure.csproj
│
├── MyApp.API/
│ ├── Controllers/
│ │ └── OrdersController.cs
│ ├── Middleware/
│ │ └── ExceptionHandlingMiddleware.cs
│ ├── Program.cs
│ └── MyApp.API.csproj
│
tests/
├── MyApp.Domain.Tests/
├── MyApp.Application.Tests/
├── MyApp.Infrastructure.Tests/
└── MyApp.API.Tests/Die Regel ist strikt: Jedes Projekt darf nur innere Schichten referenzieren, niemals aeussere. `Domain` referenziert nichts. `Application` referenziert nur `Domain`. `Infrastructure` und `API` referenzieren `Application` (und transitiv `Domain`).
Rollen der Schichten
Domain
Die Domain-Schicht ist das Herz des Systems. Sie enthaelt Entitaeten, Value Objects, Domain Events und Geschaeftsregeln ohne jegliche externe Abhaengigkeiten. Nicht einmal ein NuGet-Paket fuer Logging gehoert hierher.
public class Order
{
private readonly List<OrderItem> _items = new();
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money TotalAmount { get; private set; }
public DateTime CreatedAt { get; private set; }
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
private Order() { } class=class="code-string">"code-comment">// EF Core Konstruktor
public static Order Create(Guid customerId)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Status = OrderStatus.Draft,
TotalAmount = Money.Zero(class="code-string">"USD"),
CreatedAt = DateTime.UtcNow
};
order.AddDomainEvent(new OrderPlacedEvent(order.Id));
return order;
}
public void AddItem(string productName, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException(
class="code-string">"Artikel koennen nur zu Entwurfsbestellungen hinzugefuegt werden.");
if (quantity <= class="code-number">0)
throw new ArgumentException(
class="code-string">"Die Menge muss positiv sein.", nameof(quantity));
var item = new OrderItem(Id, productName, quantity, unitPrice);
_items.Add(item);
RecalculateTotal();
}
public void Confirm()
{
if (_items.Count == class="code-number">0)
throw new InvalidOperationException(
class="code-string">"Eine Bestellung ohne Artikel kann nicht bestaetigt werden.");
Status = OrderStatus.Confirmed;
}
private void RecalculateTotal()
{
TotalAmount = _items
.Aggregate(Money.Zero(class="code-string">"USD"),
(sum, item) => sum.Add(item.LineTotal));
}
}Beachten Sie: Alle Geschaeftsregeln leben innerhalb der Entitaet selbst. Die Bestellung weiss, dass sie ohne Artikel nicht bestaetigt werden kann. Die Bestellung weiss, wie sie ihre Gesamtsumme berechnet. Kein Service und kein Controller sollte diese Regeln duplizieren.
Application
Die Application-Schicht orchestriert Anwendungsfaelle (Use Cases). Sie definiert Befehle, Abfragen und die Schnittstellen, die von der Infrastruktur implementiert werden muessen. Ich verwende MediatR fuer das Dispatching, aber das Muster funktioniert auch mit jedem anderen Mediator oder einfachen Methodenaufrufen.
class=class="code-string">"code-comment">// Befehlsdefinition
public record PlaceOrderCommand(
Guid CustomerId,
List<OrderItemDto> Items) : IRequest<Guid>;
class=class="code-string">"code-comment">// Befehlshandler
public class PlaceOrderCommandHandler
: IRequestHandler<PlaceOrderCommand, Guid>
{
private readonly IOrderRepository _orderRepository;
private readonly IUnitOfWork _unitOfWork;
public PlaceOrderCommandHandler(
IOrderRepository orderRepository,
IUnitOfWork unitOfWork)
{
_orderRepository = orderRepository;
_unitOfWork = unitOfWork;
}
public async Task<Guid> Handle(
PlaceOrderCommand request,
CancellationToken cancellationToken)
{
var order = Order.Create(request.CustomerId);
foreach (var item in request.Items)
{
order.AddItem(
item.ProductName,
item.Quantity,
new Money(item.Price, item.Currency));
}
order.Confirm();
_orderRepository.Add(order);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return order.Id;
}
}class=class="code-string">"code-comment">// Die Repository-Schnittstelle lebt in Application, nicht in Infrastructure
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
void Add(Order order);
void Remove(Order order);
}
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}Der Handler liest sich wie ein Rezept: Bestellung erstellen, Artikel hinzufuegen, bestaetigen, speichern. Wenn sich die Geschaeftsregeln aendern, aendern Sie die Domain-Entitaet. Wenn sich der Workflow aendert, aendern Sie den Handler. Wenn sich die Datenbank aendert, bleibt hier alles unveraendert.
Infrastructure
Die Infrastructure-Schicht implementiert die in der Application-Schicht definierten Vertraege. Hier leben Entity Framework, externe API-Clients, Message Broker und Dateispeicher.
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public OrderRepository(AppDbContext context)
{
_context = context;
}
public async Task<Order?> GetByIdAsync(
Guid id, CancellationToken ct = default)
{
return await _context.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);
}
public void Add(Order order)
{
_context.Orders.Add(order);
}
public void Remove(Order order)
{
_context.Orders.Remove(order);
}
}class=class="code-string">"code-comment">// Registrierung der Infrastruktur-Dienste
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(
configuration.GetConnectionString(class="code-string">"Default")));
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IUnitOfWork>(sp =>
sp.GetRequiredService<AppDbContext>());
return services;
}
}Presentation
Controller oder Minimal-API-Endpunkte kuemmern sich ausschliesslich um HTTP-Belange: Deserialisierung von Anfragen, Dispatching von Befehlen/Abfragen und Formung von Antworten. Geschaeftslogik hat hier nichts zu suchen.
[ApiController]
[Route(class="code-string">"api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly ISender _sender;
public OrdersController(ISender sender)
{
_sender = sender;
}
[HttpPost]
public async Task<IActionResult> PlaceOrder(
PlaceOrderRequest request,
CancellationToken ct)
{
var command = new PlaceOrderCommand(
request.CustomerId, request.Items);
var orderId = await _sender.Send(command, ct);
return CreatedAtAction(
nameof(GetOrder),
new { id = orderId },
new { id = orderId });
}
[HttpGet(class="code-string">"{id:guid}")]
public async Task<IActionResult> GetOrder(Guid id, CancellationToken ct)
{
var query = new GetOrderByIdQuery(id);
var result = await _sender.Send(query, ct);
return result is null ? NotFound() : Ok(result);
}
}Praktischer Nutzen im Team
Vertical Slices vs. Schichtarchitektur: Wann was waehlen
Das ist die Debatte, die in jeder Architekturdiskussion aufkommt, und die ehrliche Antwort lautet: Die beiden Ansaetze schliessen sich nicht gegenseitig aus.
Traditionelle Schichtarchitektur organisiert Code nach technischer Zustaendigkeit. Alle Controller in einem Ordner, alle Services in einem anderen, alle Repositories in einem dritten. Das funktioniert gut, wenn Schichten hohe interne Kohaesion haben. Bei grossen Projekten muss man aber durch fuenf Ordner navigieren, um ein einzelnes Feature zu verstehen.
Vertical-Slice-Architektur organisiert Code nach Feature. Alles, was mit "Bestellung aufgeben" zu tun hat, lebt in einem Ordner: Endpunkt, Handler, Validator, Datenzugriff. Jede Scheibe ist unabhaengig und in sich abgeschlossen.
In Enterprise-.NET-Projekten, die ich als Architekt begleitet habe, verwende ich einen hybriden Ansatz: Clean-Architecture-Schichten fuer die Makrostruktur und Projektgrenzen, mit featurebasierten Ordnern innerhalb jeder Schicht. Die obige Projektstruktur spiegelt dies bereits wider. Der Ordner `Orders/Commands/PlaceOrder/` ist ein vertikaler Schnitt innerhalb der Application-Schicht.
| Aspekt | Rein geschichtet | Rein Vertical Slice | Hybrid (Empfohlen) |
|--------|-----------------|--------------------|--------------------|
| Feature-Auffindbarkeit | Niedrig | Hoch | Hoch |
| Querschnittsbelange | Einfach | Erfordert Konventionen | Ueber Behaviours gesteuert |
| Team-Skalierung | Mittel | Hoch | Hoch |
| Refactoring-Sicherheit | Hoch (mit Tests) | Mittel | Hoch |
| Lernkurve | Niedrig | Mittel | Mittel |
Die zentrale Erkenntnis: Beginnen Sie mit Clean-Architecture-Grenzen zwischen Projekten und organisieren Sie Dateien dann innerhalb dieser Projekte nach Feature. Sie erhalten die Abhaengigkeitssicherheit der Schichten zusammen mit der Navigierbarkeit der Slices.
Jede Schicht testen
Jede Schicht hat eine eigene Teststrategie. Die richtige Umsetzung ist der Unterschied zwischen einer Test-Suite, die in Sekunden laeuft, und einer, die zwanzig Minuten braucht.
Domain-Tests
Domain-Tests sind reine Unit-Tests. Kein Mocking-Framework noetig, keine Test-Container, kein Setup. Einfach die Entitaet instanziieren und das Verhalten ueberpruefen.
public class OrderTests
{
[Fact]
public void Confirm_WithItems_ShouldSetStatusToConfirmed()
{
var order = Order.Create(Guid.NewGuid());
order.AddItem(class="code-string">"Widget", class="code-number">2, new Money(class="code-number">10.00m, class="code-string">"USD"));
order.Confirm();
Assert.Equal(OrderStatus.Confirmed, order.Status);
}
[Fact]
public void Confirm_WithoutItems_ShouldThrow()
{
var order = Order.Create(Guid.NewGuid());
Assert.Throws<InvalidOperationException>(() => order.Confirm());
}
[Fact]
public void AddItem_ShouldRecalculateTotal()
{
var order = Order.Create(Guid.NewGuid());
order.AddItem(class="code-string">"Widget", class="code-number">3, new Money(class="code-number">10.00m, class="code-string">"USD"));
Assert.Equal(class="code-number">30.00m, order.TotalAmount.Amount);
}
}Application-Tests
Application-Tests ueberpruefen die Use-Case-Orchestrierung. Repository- und Unit-of-Work-Schnittstellen werden gemockt, dann wird sichergestellt, dass der Handler sie korrekt aufruft.
public class PlaceOrderCommandHandlerTests
{
[Fact]
public async Task Handle_ValidOrder_ShouldPersistAndReturnId()
{
var repo = new Mock<IOrderRepository>();
var uow = new Mock<IUnitOfWork>();
var handler = new PlaceOrderCommandHandler(repo.Object, uow.Object);
var command = new PlaceOrderCommand(
Guid.NewGuid(),
new List<OrderItemDto>
{
new(class="code-string">"Widget", class="code-number">2, class="code-number">15.00m, class="code-string">"USD")
});
var orderId = await handler.Handle(command, CancellationToken.None);
repo.Verify(r => r.Add(It.Is<Order>(o =>
o.Items.Count == class="code-number">1 && o.Status == OrderStatus.Confirmed)),
Times.Once);
uow.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()),
Times.Once);
Assert.NotEqual(Guid.Empty, orderId);
}
}Infrastructure-Tests
Infrastructure-Tests verwenden eine echte Datenbank -- typischerweise einen In-Memory-Provider oder eine Testcontainers-Instanz. Es handelt sich um Integrationstests, die sicherstellen, dass Ihre EF-Core-Mappings und Abfragen korrekt funktionieren.
public class OrderRepositoryTests : IDisposable
{
private readonly AppDbContext _context;
public OrderRepositoryTests()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new AppDbContext(options);
}
[Fact]
public async Task GetByIdAsync_ExistingOrder_ShouldReturnWithItems()
{
var order = Order.Create(Guid.NewGuid());
order.AddItem(class="code-string">"Widget", class="code-number">1, new Money(class="code-number">9.99m, class="code-string">"USD"));
_context.Orders.Add(order);
await _context.SaveChangesAsync();
var repo = new OrderRepository(_context);
var result = await repo.GetByIdAsync(order.Id);
Assert.NotNull(result);
Assert.Single(result!.Items);
}
public void Dispose() => _context.Dispose();
}API- / Integrationstests
End-to-End-Tests verwenden `WebApplicationFactory`, um die vollstaendige Pipeline hochzufahren und das HTTP-Verhalten zu verifizieren.
public class OrdersEndpointTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersEndpointTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task PlaceOrder_ShouldReturn201()
{
var request = new { CustomerId = Guid.NewGuid(),
Items = new[] { new { ProductName = class="code-string">"Widget",
Quantity = class="code-number">1, Price = class="code-number">9.99, Currency = class="code-string">"USD" } } };
var response = await _client.PostAsJsonAsync(class="code-string">"/api/orders", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
}Haeufige Architekturfehler
Dies sind Muster, die ich wiederholt in Beratungsprojekten und Produktivcodebasen gesehen habe. Jedes einzelne scheint anfangs harmlos, potenziert sich aber mit der Zeit zu ernsthaften Problemen.
1. Geschaeftslogik im Controller
Der haeufigste Verstoss. Ein Controller, der Rabatte berechnet, Lagerbestaende prueft oder E-Mails versendet, erledigt die Arbeit der Domain- und Application-Schicht. Das Ergebnis: Logik, die nicht ohne HTTP-Server getestet werden kann, und Logik, die dupliziert wird, sobald ein zweiter Endpunkt dasselbe Verhalten benoetigt.
2. Anaemisches Domain-Modell
Entitaeten, die nur aus Property-Saecken mit oeffentlichen Settern bestehen, gepaart mit "Service"-Klassen, die das gesamte Verhalten enthalten. Das unterlaueft den Zweck einer Domain-Schicht. Wenn `OrderService.Confirm(order)` existiert, fragen Sie sich, warum es kein `order.Confirm()` gibt.
3. Infrastruktur in die Application-Schicht durchsickern lassen
In dem Moment, in dem Ihr Command-Handler direkt auf `DbContext` statt auf `IOrderRepository` verweist, haben Sie Ihren Anwendungsfall an Entity Framework gekoppelt. Das erschwert das Testen und macht eine Migration zu einem anderen Datenspeicher nahezu unmoeglich, ohne Geschaeftslogik umschreiben zu muessen.
4. Alles ueberabstrahieren
`IOrderService`, `IOrderManager`, `IOrderProcessor` und `IOrderHandler` erstellen, wenn ein einzelner Command-Handler genuegen wuerde. Jede Schnittstelle sollte existieren, weil etwas ausgetauscht oder isoliert getestet werden muss. Abstraktionen ohne Konsumenten sind Rauschen.
5. Die Abhaengigkeitsregel verletzen
Die Abhaengigkeitsregel besagt, dass innere Schichten aeussere Schichten nicht kennen duerfen. Wenn Ihr `Domain`-Projekt ein `using Microsoft.EntityFrameworkCore` enthaelt, ist etwas schiefgelaufen. Verwenden Sie Architekturtests (NetArchTest oder ArchUnitNET), um dies automatisch in der CI-Pipeline durchzusetzen.
[Fact]
public void Domain_ShouldNotReference_Infrastructure()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.ShouldNot()
.HaveDependencyOn(class="code-string">"MyApp.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful);
}6. CQRS nicht einsetzen, wo es hilft
Dasselbe Modell fuer Lese- und Schreibvorgaenge zu verwenden, fuehrt zu aufgeblaehten DTOs und umstaendlichen Abfragen. Die Trennung von Lesemodellen (optimierte Projektionen) und Schreibmodellen (reichhaltige Domain-Entitaeten) vereinfacht beide Seiten erheblich. Sie brauchen kein Event Sourcing, um von CQRS zu profitieren.
Umsetzungstipps
Fazit
Clean Architecture ist am wertvollsten, wenn Produktumfang und Teamgroesse wachsen. Sie tauscht ein geringes Mass an kurzfristigem Zeremoniell gegen langfristige Stabilitaet und Klarheit. Die Schichtstruktur ist keine Zwangsjacke -- sie ist ein Satz Leitplanken, der Ihre Codebasis navigierbar und Ihre Deployments vorhersehbar haelt.
In Enterprise-.NET-Projekten, die ich als Architekt begleitet habe, haben die Teams, die diese Struktur uebernommen haben, nach der anfaenglichen Lernkurve durchweg schneller geliefert. Der Grund: Jeder Entwickler wusste genau, wo neuer Code hingehoert und wo bestehende Logik zu finden ist.
Ich unterstuetze gern bei einer pragmatischen Migration auf Clean Architecture.
Verwandte Artikel
Was ist .NET? Ein moderner Backend-Entwicklungsleitfaden
Erfahren Sie, was .NET ist, wie es funktioniert und warum es für Enterprise-Backend gewählt wird.
RESTful APIs mit ASP.NET Core entwickeln
Lernen Sie die Grundlagen für produktionsreife REST-APIs mit ASP.NET Core. Controller, Routing und Best Practices.
Dependency Injection in .NET: Grundkonzepte und Umsetzung
Verstehen und implementieren Sie Dependency Injection in .NET richtig. Service Lifetimes und Best Practices.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen