Skip to main content
SEMastery

Building Your First Use Case With Clean Architecture in .NET

A beginner-friendly, step-by-step guide to building your first use case in .NET Clean Architecture: command, handler, repository, and endpoint, with diagrams.

15 min readUpdated May 30, 2026

Cooking from a recipe card

Imagine your mother gives you one recipe card: "Make a cup of masala chai." The card lists clear steps. Boil water. Add tea leaves. Add milk and sugar. Add spices. Strain into a cup. You do not need to understand the whole kitchen to follow it. You just follow the steps on that one card.

A use case in software is exactly like that recipe card. It is one clear task your app can do, written as a short list of steps. "Register a new user." "Place an order." "Cancel a booking." Each one is its own little card.

In Clean Architecture, these recipe cards live in a special place called the Application layer. The card does not care which stove you use or which brand of milk. It just says what to do. The real stove and milk — the database, the email sender — are kept outside, in the kitchen cupboards. The card only asks for them by name.

In this guide we will write our very first recipe card together: Register a user. We will go slowly, one step at a time, and by the end you will know how to build any use case on your own.

What is a use case, really?

A use case is one job your software does for a person. Not a button, not a database table — a job.

Think of the difference. "Users table" is data. "Register a user" is a use case. The use case is the verb. It takes some input, runs a few steps, and gives back a result.

In .NET Clean Architecture, a use case is usually made of two small pieces:

  • A command (or a query) — a simple message that holds the input. A command changes data. A query only reads data.
  • A handler — the class that actually runs the steps for that command.

This split of commands (writes) and queries (reads) has a name: CQRS, which stands for Command Query Responsibility Segregation. Do not let the long name scare you. It just means: keep "things that change data" separate from "things that only read data." Each card does one thing.

Figure 1: A use case is a command (input) handled by a handler (the steps). Commands change data; queries only read it.

Where the use case lives

Before we write code, let us see where our recipe card sits in the house. Clean Architecture has four layers, like four rings. The most important rules live at the very centre, safe and protected. The database and the web live on the outside.

Figure 2: The four layers. Our use case lives in the Application layer, one ring out from the Domain. Arrows point inward.

Here is the one golden rule of the whole architecture: dependencies point inward. The outside knows about the inside, never the reverse. Our use case (Application) is allowed to use the Domain. But it is not allowed to know about the database or the web. Those are outside details.

This is why the recipe card never says "use this exact stove." It only asks for "a stove" by name, and the kitchen provides one. In code, "asking by name" means depending on an interface.

The plan for our first use case

Let us build Register a user. Here is the full plan in plain words, before any code:

Register User — the steps

Receive input
Check rules
Create user
Save
Return result

Steps

1

Receive input

Take the email and password from the request

2

Check rules

Is the email already used? Is the password strong?

3

Create user

Build a User in the Domain with its rules

4

Save

Ask the repository to store the user

5

Return result

Give back the new user's id, or an error

Each step is one small, clear job. This is the recipe card we will turn into code.

Notice we listed behaviour, not tables or HTTP. That focus is the whole point. Now let us turn each part into code, piece by piece.

Step 1: The Domain entity

The centre of the house is the Domain. It holds the User and the rules that are always true about a user. The Domain knows nothing about databases or the web.

// MyApp.Domain/Users/User.cs
namespace MyApp.Domain.Users;
 
public class User
{
    public Guid Id { get; private set; }
    public string Email { get; private set; }
    public string PasswordHash { get; private set; }
 
    private User(Guid id, string email, string passwordHash)
    {
        Id = id;
        Email = email;
        PasswordHash = passwordHash;
    }
 
    // A small factory so a User is always created in a valid way.
    public static User Create(string email, string passwordHash)
    {
        if (string.IsNullOrWhiteSpace(email))
            throw new ArgumentException("Email is required.");
 
        return new User(Guid.NewGuid(), email, passwordHash);
    }
}

The Create method makes sure a User can never exist without an email. That is a business rule, so it belongs here in the Domain, at the very centre.

Step 2: The interface the use case needs

Our recipe card needs to save the user. But saving means a database, and a database is an outside detail. So the use case does not talk to the database. Instead, it asks for what it needs by name, using an interface.

// MyApp.Application/Users/IUserRepository.cs
using MyApp.Domain.Users;
 
namespace MyApp.Application.Users;
 
public interface IUserRepository
{
    Task<bool> EmailExists(string email, CancellationToken ct);
    Task Add(User user, CancellationToken ct);
}

This is the clever part of Clean Architecture, called Dependency Inversion. The inner layer says what it needs (IUserRepository). The outer layer will later say how it is done (with EF Core). The arrow still points inward.

Figure 3: The handler uses the interface. Infrastructure implements it later. The dependency arrow points inward, into the Application layer.

Step 3: The command and the handler

Now the heart of the use case. First the command — a tiny message holding the input. It is just data, nothing clever.

// MyApp.Application/Users/RegisterUser/RegisterUserCommand.cs
namespace MyApp.Application.Users.RegisterUser;
 
public record RegisterUserCommand(string Email, string Password);
 
// A simple result so we can return success or a clear error.
public record RegisterUserResult(bool Success, Guid? UserId, string? Error);

Next the handler — the class that runs the steps on the card. Read it slowly; it follows our plan exactly.

// MyApp.Application/Users/RegisterUser/RegisterUserHandler.cs
using MyApp.Domain.Users;
 
namespace MyApp.Application.Users.RegisterUser;
 
public class RegisterUserHandler(
    IUserRepository users,
    IPasswordHasher hasher)
{
    public async Task<RegisterUserResult> Handle(
        RegisterUserCommand command,
        CancellationToken ct)
    {
        // Step 1: check the rule — is the email already taken?
        if (await users.EmailExists(command.Email, ct))
            return new RegisterUserResult(false, null, "Email already in use.");
 
        // Step 2: hash the password (never store plain text!)
        var hash = hasher.Hash(command.Password);
 
        // Step 3: create the user in the Domain
        var user = User.Create(command.Email, hash);
 
        // Step 4: save through the interface
        await users.Add(user, ct);
 
        // Step 5: return the result
        return new RegisterUserResult(true, user.Id, null);
    }
}

Look how readable that is. Anyone — even someone new to the project — can read Handle top to bottom and understand the whole task. There is no SQL, no HTTP, no framework noise. Just the recipe. That clarity is what we are working so hard to protect.

The handler also asks for an IPasswordHasher. Same idea as the repository: the use case names what it needs, and the outside provides it.

Step 4: The Infrastructure implementation

Now we step out to the kitchen. The Infrastructure layer provides the real stove — the actual database code using EF Core. It implements the interface our use case asked for.

// MyApp.Infrastructure/Persistence/UserRepository.cs
using Microsoft.EntityFrameworkCore;
using MyApp.Application.Users;
using MyApp.Domain.Users;
 
namespace MyApp.Infrastructure.Persistence;
 
public class UserRepository(AppDbContext db) : IUserRepository
{
    public Task<bool> EmailExists(string email, CancellationToken ct) =>
        db.Users.AnyAsync(u => u.Email == email, ct);
 
    public async Task Add(User user, CancellationToken ct) =>
        await db.Users.AddAsync(user, ct);
}

Our handler never sees this class. It only ever sees IUserRepository. If you switched from SQL Server to PostgreSQL, or even to a simple in-memory store, the handler would not change a single line. That is the freedom Clean Architecture buys you.

Step 5: The endpoint that calls the use case

Finally, the front door — the Presentation layer. A minimal API endpoint receives the HTTP request, builds the command, and calls the handler. It is thin on purpose. It does no business logic itself.

// MyApp.Api/Endpoints/UserEndpoints.cs
using MyApp.Application.Users.RegisterUser;
 
public static class UserEndpoints
{
    public static void MapUserEndpoints(this WebApplication app)
    {
        app.MapPost("/users/register", async (
            RegisterUserCommand command,
            RegisterUserHandler handler,
            CancellationToken ct) =>
        {
            var result = await handler.Handle(command, ct);
 
            return result.Success
                ? Results.Created($"/users/{result.UserId}", result)
                : Results.BadRequest(result.Error);
        });
    }
}

The endpoint is like a waiter. It takes the order from the customer, hands it to the kitchen (the handler), and brings back the plate (the result). It does not cook.

How the request travels

Let us watch one real request flow through all our pieces. It travels inward, does the work, and comes back out.

Figure 4: A register request flows from the API to the handler, through the Domain rule, out to the repository, and back as a 201 Created.

Each box has one clear job. The endpoint translates HTTP. The handler runs the steps. The Domain holds the rules. The repository touches the database. Nothing is tangled.

Wiring it all together

The pieces live in different projects, so someone has to connect them at startup. That someone is the API project — the "composition root." It is the only place allowed to know about every layer.

// MyApp.Api/Program.cs
var builder = WebApplication.CreateBuilder(args);
 
// Register the handler and the implementations
builder.Services.AddScoped<RegisterUserHandler>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IPasswordHasher, PasswordHasher>();
 
var app = builder.Build();
app.MapUserEndpoints();
app.Run();

When a request arrives, .NET's dependency injection sees that the endpoint needs a RegisterUserHandler, that the handler needs an IUserRepository, and that the real UserRepository fits. It builds the chain for you and hands it over. You just declare what fits what.

Do I need MediatR?

You may have seen tutorials that send commands to handlers using a library called MediatR. It is popular, but here is something important to know.

Since July 2, 2025, MediatR moved to a commercial license for its newer versions (version 13 and later). Older versions stay open source, but new ones need a paid plan for many business uses. The same company's MassTransit also moved to a commercial model. So MediatR is no longer automatically free for every project.

The good news: you do not need it at all to build use cases. Everything in this guide used plain classes and plain method calls. That is the simplest, most honest way to learn the pattern, and it has zero extra cost.

ApproachWhat it isGood when
Plain handler (this guide)Call handler.Handle(...) directlyLearning, small and medium apps, no extra cost
Tiny custom dispatcherA small class you write that finds the right handlerYou want one entry point but no paid library
MediatRA library that routes commands to handlersA large existing codebase already using it, and a license is fine

Start with plain handlers. Add a dispatcher only when you truly feel the need. Most apps never do.

Commands vs queries side by side

Our example was a command because it changed data. A query is the read side. They look similar but have an important difference.

CommandQuery
PurposeChange data (create, update, delete)Read data only
ExampleRegisterUser, PlaceOrderGetUserById, ListOrders
ReturnsSuccess or an idThe data you asked for
Changes the database?YesNo, never

Keeping these two apart is the core idea of CQRS. A reader can tell, just from the name, whether a use case is safe (a query) or whether it changes something (a command). That makes the whole codebase easier to trust.

Why this is worth the effort

A beginner might ask: "This is a lot of files just to register a user. Why not put it all in one controller?" Fair question. Here is the payoff.

The payoffs of one clean use case

Readable
Testable
Swappable
Growable

Steps

1

Readable

The handler reads like the recipe — no SQL or HTTP noise

2

Testable

Test the handler with a fake repository, no database needed

3

Swappable

Change the database without touching the use case

4

Growable

Add the next use case as a new card, side by side

Each property comes from keeping the steps separate and the database outside.

The testability point is the big one. Because the handler depends on IUserRepository and not on EF Core, your test can pass a tiny fake repository that lives in memory. The test runs in milliseconds and checks real behaviour: "if the email exists, return an error." No database, no web server, no waiting.

// A tiny fake for testing — no real database
public class FakeUserRepository : IUserRepository
{
    public bool EmailIsTaken { get; set; }
    public Task<bool> EmailExists(string email, CancellationToken ct) =>
        Task.FromResult(EmailIsTaken);
    public Task Add(User user, CancellationToken ct) => Task.CompletedTask;
}

With that fake, testing the "email already used" rule takes a few lines and zero setup. This is only possible because we kept the database on the outside and asked for it by name.

Common beginner mistakes to avoid

A few traps catch almost everyone the first time. Watch for these:

  • Putting business rules in the endpoint. The endpoint should only translate HTTP. If you see if statements about business rules in your endpoint, move them into the handler.
  • Letting the handler use EF Core directly. If your handler has DbContext in it, the database has leaked inward. Hide it behind a repository interface.
  • A use case that does five jobs. One card, one job. If "register user" also sends a newsletter and updates analytics, split those out.
  • Returning the Domain entity straight to the web. Return a small result or DTO instead, so your API shape and your Domain can change separately.

Each mistake breaks the golden rule in some small way. When in doubt, ask: "Is this thing a business step, or an outside detail?" Steps go inward; details go outward.

Quick recap

  • A use case is one real task your app does, like "register a user" — a recipe card of clear steps.
  • It lives in the Application layer as a command (changes data) or a query (reads data), with a handler that runs the steps. This split is CQRS.
  • The handler never touches the database directly. It depends on an interface like IUserRepository; the Infrastructure layer implements it. This is Dependency Inversion.
  • The endpoint is thin — it builds the command, calls the handler, and returns the result. No business logic.
  • You do not need MediatR; plain handlers work great, and MediatR's newer versions became commercially licensed in July 2025.
  • The big payoffs are readable code, fast tests with fake repositories, and the freedom to swap the database without touching your use case.

You have now built a complete use case from the centre outward: a Domain entity with its rule, an interface for what the use case needs, a command and handler that read like the recipe, a real database implementation on the outside, and a thin endpoint at the front door. The next use case follows the very same shape — a new card on the shelf, sitting neatly beside this one. Keep each card small, keep the database outside, and let every dependency point inward. Do that, and your application can grow for years while each task stays as clear and calm as a single recipe on your mother's kitchen card.

References and further reading

Related Posts