Clean Architecture in .NET: Skalierbare Projektstruktur

15 Min. Lesezeit9. Februar 2026Aktualisiert: 9. März 2026
.NET clean architectureClean architecture C#.NET project structureSOLID .NETOnion architecture.NET layersDomain driven design .NETscalable .NET

# 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.

csharp
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.

csharp
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;
    }
}
csharp
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.

csharp
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);
    }
}
csharp
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.

csharp
[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

  • **Klare Grenzen reduzieren versehentliche Kopplung.** Wenn jeder Entwickler weiss, dass Datenbankcode in `Infrastructure` und Geschaeftsregeln in `Domain` gehoeren, werden Code-Reviews schneller und zielgerichteter.
  • **Unit-Tests werden schneller und guenstiger.** Domain- und Application-Schicht haben keine Infrastrukturabhaengigkeiten, sodass Tests in Millisekunden laufen -- ohne Datenbanken oder Container.
  • **Infrastrukturaenderungen haben einen kleineren Wirkungsradius.** Der Wechsel von PostgreSQL zu CosmosDB bedeutet, Repositories neu zu schreiben -- nicht die Geschaeftslogik. Ich habe diesen Wechsel in einem Produktivsystem ohne eine einzige Aenderung in der Application-Schicht durchgefuehrt.
  • **Parallele Entwicklung ueber Teams hinweg wird einfacher.** Ein Team kann die Zahlungsintegration bauen, waehrend ein anderes den Bestellworkflow entwickelt. Sie treffen sich an der Schnittstellengrenze.
  • 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.

    csharp
    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.

    csharp
    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.

    csharp
    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.

    csharp
    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.

    csharp
    [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

  • **Vermeiden Sie fruehzeitiges Over-Engineering.** Beginnen Sie mit zwei Projekten (API + Domain) und trennen Sie Application und Infrastructure erst, wenn drei oder mehr Anwendungsfaelle die Trennung rechtfertigen.
  • **Halten Sie Anwendungsfaelle explizit und klein.** Ein Command-Handler sollte genau eine Sache tun. Wenn ein Handler fuenfzig Zeilen ueberschreitet, macht er wahrscheinlich zu viel.
  • **Bevorzugen Sie featurebasierte Ordner gegenueber technischen Mega-Ordnern.** Ein Ordner namens `Orders/Commands/PlaceOrder/` ist unendlich besser navigierbar als ein Ordner namens `Handlers/` mit vierzig Dateien.
  • **Fuegen Sie Architekturtests hinzu, um Grenzen durchzusetzen.** Menschen vergessen Regeln. CI nicht.
  • **Verwenden Sie MediatR-Pipeline-Behaviours fuer Querschnittsbelange.** Validierung, Logging und Transaktionsmanagement gehoeren in Behaviours, nicht verstreut ueber jeden Handler.
  • 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

    Haben Sie ein Flutter-Projekt?

    Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.

    Kontakt aufnehmen