Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
149979 stories
·
33 followers

Enterprise Patterns for ASP.NET Core Minimal API: Domain Model Pattern – When Your Core Rules Deserve Their Own Gravity

1 Share

Look at a typical enterprise ASP.NET Core application, and you often see the same pattern:

  • Controllers validating requests, calculating totals, and applying discounts
  • EF Core entities that are little more than property bags
  • Stored procedures that quietly decide which orders are valid

If you need to know how orders work, you do not open a single file. You read controllers, queries, and database scripts until your eyes blur. The truth about the business lives everywhere and nowhere.

The Domain Model is the pattern that reverses this arrangement.

Instead of clever controllers and dumb entities, you move the rules into rich objects. Entities and value objects enforce invariants. The application layer orchestrates use cases by telling those objects what to do.

This post shows what that looks like in C#, and why putting rules next to data changes how your system behaves over time.

What Domain Model Really Is

In Fowler’s terms, a Domain Model:

  • Represents the business domain with rich objects
  • Encapsulates rules and invariants inside those objects
  • Treats the framework, database, and transport as details at the edges

In practical .NET terms:

  • Your Order type knows what a valid order looks like
  • Your Customer type knows whether it is eligible for a specific feature
  • Controllers, message handlers, or background jobs call methods on those types

What it is not:

  • It is not simply having classes called Order and Customer with auto properties
  • It is not pushing every rule into a single God object
  • It is not a diagram alone, while the code keeps all the rules in the controllers

The whole point is to make the rules you care about first-class citizens in your code.

A Concrete Domain Model Slice

Here is a small, but real, Order aggregate with OrderLine in C#.

public class Order
{
    private readonly List<OrderLine> _lines = new();

    private Order(Guid customerId)
    {
        Id = Guid.NewGuid();
        CustomerId = customerId;
        Status = OrderStatus.Draft;
    }

    public Guid Id { get; }
    public Guid CustomerId { get; }
    public OrderStatus Status { get; private set; }
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
    public decimal TotalAmount => _lines.Sum(l => l.Total);

    public static Order Create(Guid customerId, IEnumerable<OrderLine> lines)
    {
        var order = new Order(customerId);

        foreach (var line in lines)
        {
            order.AddLine(line.ProductId, line.Quantity, line.UnitPrice);
        }

        if (!order._lines.Any())
        {
            throw new InvalidOperationException("Order must have at least one line.");
        }

        return order;
    }

    public void AddLine(Guid productId, int quantity, decimal unitPrice)
    {
        if (Status != OrderStatus.Draft)
        {
            throw new InvalidOperationException("Cannot change a non draft order.");
        }

        if (quantity <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(quantity));
        }

        if (unitPrice <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(unitPrice));
        }

        _lines.Add(new OrderLine(productId, quantity, unitPrice));
    }

    public void ApplyDiscount(decimal percent)
    {
        if (percent <= 0 || percent >= 50)
        {
            throw new ArgumentOutOfRangeException(nameof(percent));
        }

        foreach (var line in _lines)
        {
            line.ApplyDiscount(percent);
        }
    }

    public void Submit()
    {
        if (Status != OrderStatus.Draft)
        {
            throw new InvalidOperationException("Only draft orders can be submitted.");
        }

        if (!_lines.Any())
        {
            throw new InvalidOperationException("Cannot submit an empty order.");
        }

        Status = OrderStatus.Submitted;
    }
}

public class OrderLine
{
    public OrderLine(Guid productId, int quantity, decimal unitPrice)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(unitPrice);

        ProductId = productId;
        Quantity = quantity;
        UnitPrice = unitPrice;
    }

    public Guid ProductId { get; }
    public int Quantity { get; }
    public decimal UnitPrice { get; private set; }
    public decimal Total => Quantity * UnitPrice;

    public void ApplyDiscount(decimal percent)
    {
        UnitPrice = UnitPrice * (1 - percent / 100m);
    }
}

public enum OrderStatus
{
    Draft = 0,
    Submitted = 1,
    Cancelled = 2
}

Notice what is happening here:

  • Creation is controlled through Order.Create, not through new Order() scattered everywhere
  • Order refuses to exist without at least one OrderLine
  • AddLine and ApplyDiscount validate arguments and enforce state transitions
  • Submit enforces that only draft orders are submitted and that empty orders are invalid

The rules live with the data. You no longer need to remember, in every controller, how discounts work or when an order may be modified.

Before Domain Model: Controller As Decision Maker

Most enterprise apps start closer to this shape:

app.MapPost("/orders", async (CreateOrderDto dto, AppDbContext db) =>
{
    if (dto.Lines is null || dto.Lines.Count == 0)
    {
        return Results.BadRequest("Order must have at least one line.");
    }

    var orderEntity = new OrderEntity
    {
        Id = Guid.NewGuid(),
        CustomerId = dto.CustomerId,
        Status = "Draft",
        CreatedAt = DateTime.UtcNow
    };

    foreach (var lineDto in dto.Lines)
    {
        if (lineDto.Quantity <= 0)
        {
            return Results.BadRequest("Quantity must be positive.");
        }

        orderEntity.Lines.Add(new OrderLineEntity
        {
            ProductId = lineDto.ProductId,
            Quantity = lineDto.Quantity,
            UnitPrice = lineDto.UnitPrice
        });
    }

    db.Orders.Add(orderEntity);
    await db.SaveChangesAsync();

    return Results.Created($"/orders/{orderEntity.Id}", new { orderEntity.Id });
});

And later, somewhere else, a discount endpoint:

app.MapPost("/orders/{id:guid}/discounts", async (
    Guid id,
    ApplyDiscountDto dto,
    AppDbContext db) =>
{
    var order = await db.Orders
        .Include(o => o.Lines)
        .SingleOrDefaultAsync(o => o.Id == id);

    if (order == null)
    {
        return Results.NotFound();
    }

    if (order.Status != "Draft")
    {
        return Results.BadRequest("Cannot change a non draft order.");
    }

    if (!order.Lines.Any())
    {
        return Results.BadRequest("Cannot discount an empty order.");
    }

    if (dto.Percent <= 0 || dto.Percent >= 50)
    {
        return Results.BadRequest("Discount percent out of range.");
    }

    foreach (var line in order.Lines)
    {
        line.UnitPrice = line.UnitPrice * (1 - dto.Percent / 100m);
    }

    await db.SaveChangesAsync();

    return Results.Ok(new { order.Id });
});

The same rules are repeated in different forms:

  • Draft status checks
  • Non-empty order checks
  • Discount percent range checks
  • Positive quantity rules

The code works until a new rule arrives and someone updates one endpoint but misses the others.

After Domain Model: Controller As Orchestrator

Now see how the controller changes when you let the domain model handle behavior.

Assume you already use the Order aggregate from earlier and have a repository.

public interface IOrderRepository
{
    Task AddAsync(Order order, CancellationToken cancellationToken = default);
    Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
}

Creating an Order

app.MapPost("/orders", async (
    CreateOrderDto dto,
    IOrderRepository orders,
    CancellationToken ct) =>
{
    var lines = dto.Lines.Select(l =>
        new OrderLine(l.ProductId, l.Quantity, l.UnitPrice));

    Order order;

    try
    {
        order = Order.Create(dto.CustomerId, lines);
    }
    catch (Exception ex) when (ex is ArgumentOutOfRangeException || ex is InvalidOperationException)
    {
        return Results.BadRequest(ex.Message);
    }

    await orders.AddAsync(order, ct);

    return Results.Created($"/orders/{order.Id}", new { order.Id });
});

public record CreateOrderDto(
    Guid CustomerId,
    List<CreateOrderLineDto> Lines);

public record CreateOrderLineDto(
    Guid ProductId,
    int Quantity,
    decimal UnitPrice);

The endpoint now:

  • Translates input DTOs into domain OrderLine objects
  • Delegates invariant to Order.Create and OrderLine constructors
  • Catches domain exceptions and maps them to HTTP responses

The logic that defines a valid order lives inside Order, not inside the endpoint.

Applying a Discount

app.MapPost("/orders/{id:guid}/discounts", async (
    Guid id,
    ApplyDiscountDto dto,
    IOrderRepository orders,
    CancellationToken ct) =>
{
    var order = await orders.GetByIdAsync(id, ct);

    if (order == null)
    {
        return Results.NotFound();
    }

    try
    {
        order.ApplyDiscount(dto.Percent);
    }
    catch (ArgumentOutOfRangeException ex)
    {
        return Results.BadRequest(ex.Message);
    }

    await orders.AddAsync(order, ct); // or SaveChanges via Unit of Work

    return Results.Ok(new { order.Id, order.TotalAmount });
});

public record ApplyDiscountDto(decimal Percent);

The discount rule is expressed once, in the domain model:

public void ApplyDiscount(decimal percent)
{
    if (percent <= 0 || percent >= 50)
    {
        throw new ArgumentOutOfRangeException(nameof(percent));
    }

    foreach (var line in _lines)
    {
        line.ApplyDiscount(percent);
    }
}

Controllers have one job:

  • Load the aggregate
  • Tell it what to do
  • Persist the result
  • Translate domain errors to responses

That is the essence of Domain Model in a web app.

Why Putting Rules Next To Data Matters

Shifting behavior into domain objects does more than make code “cleaner”. It changes several properties of your system.

One Place To Ask “What Is The Rule”

If a product owner asks:

What exactly are the conditions for applying a discount?

You can answer by opening Order.ApplyDiscount and related collaborators. There is no tour of controllers, repositories, and stored procedures.

Transport Independence

Imagine you want a background service that runs a nightly promotion:

  • It reads eligible orders from the database
  • It applies a discount to each
  • It sends confirmation emails

With a Domain Model, this worker calls the same ApplyDiscount method that your HTTP endpoint uses. If you switch to messaging or add a gRPC API, they all reuse the same behavior.

Stronger, Cheaper Tests

You can write unit tests directly against Order:

[Fact]
public void ApplyDiscount_Throws_WhenPercentOutOfRange()
{
    var order = Order.Create(
        Guid.NewGuid(),
        new[] { new OrderLine(Guid.NewGuid(), 1, 100m) });

    Assert.Throws<ArgumentOutOfRangeException>(() => order.ApplyDiscount(0));
    Assert.Throws<ArgumentOutOfRangeException>(() => order.ApplyDiscount(60));
}

[Fact]
public void Submit_SetsStatusToSubmitted_WhenDraftAndHasLines()
{
    var order = Order.Create(
        Guid.NewGuid(),
        new[] { new OrderLine(Guid.NewGuid(), 1, 100m) });

    order.Submit();

    Assert.Equal(OrderStatus.Submitted, order.Status);
}

No test server, no HTTP, no database. You can exhaustively test the behavior that matters while keeping integration tests focused on wiring.

Integrating Domain Model With Application And Infrastructure

Domain Model does not live alone. It cooperates with:

  • An application layer that coordinates use cases
  • An infrastructure layer that persists, aggregates, and talks to external systems

A typical setup in .NET:

  • MyApp.Domain
    • Entities, value objects, domain services and interfaces for repositories
  • MyApp.Application
    • Application services that orchestrate commands and queries
  • MyApp.Infrastructure
    • EF Core mappings, repository implementations, unit of work
  • MyApp.Web
    • Controllers or minimal APIs that call application services

Example application service using Order:

public interface IOrderApplicationService
{
    Task<Guid> CreateOrderAsync(CreateOrderCommand command, CancellationToken ct = default);
}

public class OrderApplicationService : IOrderApplicationService
{
    private readonly IOrderRepository _orders;

    public OrderApplicationService(IOrderRepository orders)
    {
        _orders = orders;
    }

    public async Task<Guid> CreateOrderAsync(CreateOrderCommand command, CancellationToken ct = default)
    {
        var lines = command.Lines.Select(l =>
            new OrderLine(l.ProductId, l.Quantity, l.UnitPrice));

        var order = Order.Create(command.CustomerId, lines);

        await _orders.AddAsync(order, ct);

        return order.Id;
    }
}

public record CreateOrderCommand(
    Guid CustomerId,
    IReadOnlyCollection<CreateOrderLineCommand> Lines);

public record CreateOrderLineCommand(
    Guid ProductId,
    int Quantity,
    decimal UnitPrice);

Controllers or endpoints call IOrderApplicationService, not Order directly. That keeps HTTP details and use case orchestration together, while the domain model stays focused on rules.

Signs You Are Pretending To Have A Domain Model

Many teams say, “We are doing DDD,” while their code tells a different story. Look for these patterns.

  • Entities with only auto properties and no behavior
  • Controllers or handlers performing status transitions and complex validations
  • Stored procedures implementing key rules, such as discount criteria or eligibility
  • Domain types that depend directly on DbContext or HttpContext

If any of those describe your system, you have building blocks for a domain model, not an actual model.

First Steps Toward A Real Domain Model

You do not need a significant rewrite. Start small.

  1. Pick one important concept
    Order, Subscription, Invoice, or any aggregate that matters to the business.
  2. Move a single rule into that entity
    For example, “order must have at least one line” or “cannot modify submitted orders”.
  3. Expose behavior, not just state
    Add methods like AddLine, ApplyDiscount, Submit, instead of letting the outside world mutate collections directly.
  4. Write tests against the entity
    Prove that the rules hold even when no controller or database is involved.
  5. Refactor controllers to call the domain model
    Remove duplicated checks, catch domain exceptions, map them to HTTP responses.

Repeat that in the parts of the system that hurt the most. Over time, the gravity of the domain model grows, and the framework falls into its proper role as plumbing.

If your core rules are worth money, they are worth a real home in your code. Treat them as the main asset, not as an afterthought squeezed into controllers and stored procedures.

The post Enterprise Patterns for ASP.NET Core Minimal API: Domain Model Pattern – When Your Core Rules Deserve Their Own Gravity first appeared on Chris Woody Woodruff | Fractional Architect.

Read the whole story
alvinashcraft
20 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Load Testing ASP.NET Core Applications with k6: Practical Implementation

1 Share
Load Testing ASP.NET Core Applications with k6: Practical Implementation
Read the whole story
alvinashcraft
25 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Load Testing ASP.NET Core Applications with k6: Introduction

1 Share
Load Testing ASP.NET Core Applications with k6: Introduction
Read the whole story
alvinashcraft
30 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

mostlylucid.MinimalBlog-How Simple Can an ASP.NET Blog Really Be?

1 Share
mostlylucid.MinimalBlog-How Simple Can an ASP.NET Blog Really Be?
Read the whole story
alvinashcraft
42 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Podcast: Looking for Root Causes is a False Path: A Conversation with David Blank-Edelman

1 Share

In this podcast, Michael Stiefel spoke with David Blank-Edelman about the relationship between software architecture and site reliability engineering. Site reliability engineering can give architecture vital feedback about how the system actually behaves in production. Architects and designers can then learn from their failures to improve their ability to build systems that can evolve.

By David Blank-Edelman
Read the whole story
alvinashcraft
47 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Every Intel GPU runs on a Raspberry Pi

1 Share

In the high-tech world we live in today, the lines between what’s conceivable and what’s available are constantly being blurred, especially in the realm of computing. Imagine for a moment, a compact and affordable computer system capable not only of managing daily tasks but also capable of running advanced graphics applications. This brings me to an intriguing development in the tech space — the harmonization of Intel Arc GPUs with the ever-popular Raspberry Pi systems.

We’ve recently seen a breakthrough that might just change the game for hobbyists, developers, and tech enthusiasts who rely on the versatility and affordability of Raspberry Pi. The integration of Intel Arc graphics cards with these systems isn’t just a technical achievement; it’s a step towards democratizing more powerful computing on smaller and more accessible devices.

This video is from Jeff Geerling.

The challenges were aplenty. Getting Intel Arc GPUs to work with the Raspberry Pi involved overcoming numerous hurdles, but the results are promising. This compatibility means that the engrossing could soon be incorporated into the Raspberry Pi OS with a simple patch — a prospect that could bring sophisticated graphics capabilities to these budget-friendly platforms.

Among the cards tested, the Intel A750 and the A310 Eco stand out. The latter, notably a budget-friendly option priced at $100 for a 4GB version, sheds light on the cost efficiency of such an endeavor. This ordeal wasn’t without its quirks, such as the A310’s sensitivity to PCI Express link signal integrity. But solutions like using a solid dock and adapter, or a dedicated M.2 adapter with a built-in PCI Express redriver, have proven effective.

The setup doesn’t require any heroic feats. It’s almost plug-and-play, minus some tweaks here and there. Simply connecting your Raspberry Pi to an eGPU dock via a PCI Express adapter opens up a whole new realm of possibilities. The process now is more streamlined than ever — thankfully no Linux kernel recompilation needed, sparing me the frequent wearing of my recompile Linux shirt.

But not all is perfect in paradise just yet. For one, Intel drivers can be finicky, especially on non-x86 systems such as the Raspberry Pi. These systems necessitate specific firmware installations depending on the card used, and additional steps to force the drivers to recognize the unconventional system architecture. For instance, tweaking the BIOS settings to force-probe all devices can prevent the Intel drivers from balking at an “unsupported system.”

Moreover, challenges with memory mappings hint at a deeper architectural dilemma within Intel’s drivers when adapted to ARM architectures. Rendering issues and artifacts in the user interface, among other glitches, point to this mismatch. Even running certain applications, like AI models, can unveil these quirks — smaller models run without a hitch, but push the system with more demanding tasks, and you might encounter issues.

All things considered, the performance of Intel graphics cards on Raspberry Pi systems, while not ready to win any speed records, is commendable given the compact and cost-effective nature of the setup. This development not only caters to those looking to build a HomeLab or experiment with AI but also heralds a new era of accessibility in computing.

Moving forward, the roadmap involves garnering support at the core kernel level and ensuring these solutions are robust across various non-x86 systems. Getting these changes upstream into the Linux kernel is pivotal. Once solidified, this integration could redefine how we perceive and utilize microcomputing platforms, potentially transforming Raspberry Pi devices into even more versatile tools.

For my fellow tech adventurers, this represents not just a leap in graphical computing on micro-devices but also a beacon guiding us towards a more inclusive future in technology. Where will this journey take us next? Only time—and plenty of tinkering—will tell.

Read the whole story
alvinashcraft
1 minute ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories