REPR Pattern in .NET for Clean API Architecture

RickRick
5 min read

After implementing Domain-Driven Design for a loan management system, I want to share another architectural pattern that complements my DDD approach perfectly: REPR (Request-Endpoint-Response). This pattern has transformed how I structure my API endpoints, making them more maintainable and testable.

The Challenge with API Endpoints

Traditional API controllers often become bloated with numerous actions, making them difficult to test and maintain. As applications grow, these controllers can accumulate thousands of lines of code, mixing concerns and creating a maintenance nightmare.

Many developers organize APIs by controller, resulting in classes with multiple endpoints that handle different operations on the same resource. While this seems logical at first, it quickly becomes unwieldy as the application grows.

Enter the REPR Pattern

REPR stands for Request-Endpoint-Response, a pattern that focuses on creating dedicated classes for each API endpoint. Instead of controllers with multiple actions, each endpoint is its own class with a single responsibility.

This approach aligns perfectly with SOLID principles, particularly the Single Responsibility Principle. Let's see how I've implemented this using FastEndpoints, a lightweight library that facilitates the REPR pattern in .NET.

Implementation in My Loan Management System

Looking at my loan submission endpoint, you can see how clean and focused the implementation is:

internal class SubmitLoanEndPoint(IStorageProvider storageProvider, ILoanPublisher loanPublisher)
    :Endpoint<SubmitLoanRequest, SubmitLoanResponse>
{
    public override void Configure()
    {
        Post("/");
        Group<LoanGroup>(); 
        AllowAnonymous();
        Summary(s =>
        {
            s.Summary = "Submit a loan application";
            s.Description = "Submit a loan application to the system";
            s.Response<SubmitLoanResponse>();
            s.Response(400, "Invalid request");
            s.Response(500, "Internal server error");
        });
    }

    public override async Task HandleAsync(SubmitLoanRequest req, CancellationToken ct)
    {
        //1.- Create the loan entity
        var loanEntity = new LoanEntity
        {
            LoanId = Guid.NewGuid().ToString(),
            LoanAmount = req.LoanAmount,
            LoanTerm = req.LoanTerm,
            LoanPurpose = req.LoanPurpose,
            BankInformation= new BankInformationEntity(req.BankInformation.BankName, 
                req.BankInformation.AccountType, req.BankInformation.AccountNumber),
            PersonalInformation = new PersonalInformationEntity(req.PersonalInformation.FullName, 
                req.PersonalInformation.Email, 
                DateOnly.FromDateTime(req.PersonalInformation.DateOfBirth)),
        };

        //2.- Save the loan entity to the storage provider
        var loanCreationResult = await storageProvider.CreateLoanAsync(loanEntity);

        //3.- Publish the loan creation event to the message broker
        var loanCreationRequest = new global::Loan.Shared.Contracts.Commands.SubmitLoanRequest(
            loanCreationResult.LoanId,// we pass the loan ID from the storage provider
            req.LoanAmount,
            req.LoanTerm,
            req.LoanPurpose,
            new(req.BankInformation.BankName, req.BankInformation.AccountType, 
                req.BankInformation.BankName),
            new(req.PersonalInformation.FullName, req.PersonalInformation.Email, 
                req.PersonalInformation.DateOfBirth)
        );

        await loanPublisher.PublishLoanSubmittedAsync(loanCreationRequest, ct);

        //4.- Return the loan ID to the client
        await SendOkAsync(new SubmitLoanResponse(loanCreationResult.LoanId), cancellation: ct);
    }
}

Key Components of the REPR Pattern

1. Request Objects

Request objects define the incoming data structure. They're immutable records that clearly communicate what data is needed for this specific endpoint:

internal record SubmitLoanRequest(int LoanAmount,
                                 int LoanTerm,
                                 int LoanPurpose,
                                 BankInfo BankInformation,
                                 PersonalInfo PersonalInformation);

The supporting data types are also simple and immutable:

internal record BankInfo(string BankName, string AccountType, string AccountNumber);

public record PersonalInfo(string FullName, string Email, DateTime DateOfBirth);

2. Endpoint Classes

Each endpoint is a dedicated class that inherits from Endpoint<TRequest, TResponse>. This class has one job: handle a specific API request. The configuration is declarative and self-documented:

public override void Configure()
{
    Post("/");
    Group<LoanGroup>(); 
    AllowAnonymous();
    Summary(s =>
    {
        s.Summary = "Submit a loan application";
        s.Description = "Submit a loan application to the system";
        s.Response<SubmitLoanResponse>();
        s.Response(400, "Invalid request");
        s.Response(500, "Internal server error");
    });
}

Notice how we're using a LoanGroup to organize our endpoints logically:

internal class LoanGroup : Group
{
    public LoanGroup()
    {
        Configure(nameof(Loan).ToLower(), _ =>
        {
            // Group configuration
        });
    }
}

3. Validation

Validation is separated into its own class, keeping the endpoint class clean:

internal class SubmitLoanRequestValidator : AbstractValidator<SubmitLoanRequest>
{
    public SubmitLoanRequestValidator()
    {
        RuleFor(x => x.LoanAmount)
            .NotEmpty()
            .WithMessage("Loan amount is required")
            .GreaterThan(0)
            .WithMessage("Loan amount must be greater than 0");
        RuleFor(x => x.LoanTerm)
            .NotEmpty()
            .WithMessage("Loan term is required")
            .GreaterThan(0)
            .WithMessage("Loan term must be greater than 0");
        RuleFor(x => x.LoanPurpose)
            .NotEmpty()
            .WithMessage("Loan purpose is required");
        RuleFor(x => x.BankInformation)
            .NotNull()
            .WithMessage("Bank information is required");
        RuleFor(x => x.PersonalInformation)
            .NotNull()
            .WithMessage("Personal information is required");
    }
}

4. Response Objects

Response objects are also simple records that define the expected output:

internal record SubmitLoanResponse(string LoanId);

Integration with Event-Driven Architecture

My implementation doesn't stop at the API level. After processing the request, we publish an event to notify other systems about the loan submission:

await loanPublisher.PublishLoanSubmittedAsync(loanCreationRequest, ct);

The message publishing interface is simple:

internal interface ILoanPublisher
{
    Task PublishLoanSubmittedAsync(SubmitLoanRequest command, CancellationToken cancellationToken);
}

And the event itself extends a base message class:

public record LoanSubmitted(string LoanId, int LoanStatus) : BaseMessage;

public abstract record BaseMessage
{
    public Guid MessageId { get; set; } = Guid.NewGuid();
    public DateTime Timestamp { get; set; } = DateTime.UtcNow;
    public string Version { get; set; } = "1.0";
}

Advantages of the REPR Pattern

  1. Focused Responsibility: Each endpoint handles exactly one API operation, making the code easier to understand and maintain.

  2. Improved Testability: Testing is simplified because each endpoint is focused on a single task with clear inputs and outputs.

  3. Easier to Extend: Adding new endpoints doesn't require modifying existing code, adhering to the Open/Closed Principle.

  4. Self-Documented API: The configuration method clearly defines the endpoint's behavior, making the API easier to understand.

  5. Simplified Dependency Injection: Dependencies are injected only where needed, reducing unnecessary coupling.

  6. Clean Architecture Support: This pattern fits perfectly with clean architecture and DDD principles.

Potential Improvements

Looking at my SubmitLoanEndPoint implementation, there's a TODO comment about implementing the outbox pattern:

//TODO: apply outbox pattern

This would further improve the system's resilience by ensuring that the message publishing is transactionally consistent with the database operations.

Conclusion

The REPR pattern, combined with Domain-Driven Design, has significantly improved the maintainability and scalability of my loan management system. By creating focused, single-responsibility endpoints, the code becomes more readable, testable, and extensible.

If you're struggling with bloated controllers in your API, consider giving the REPR pattern a try. Libraries like FastEndpoints make it easy to implement in .NET applications, and the benefits become increasingly apparent as your application grows.

Remember: good architecture isn't about following patterns blindly but understanding your domain's complexity and choosing patterns that help manage that complexity effectively. In my experience, the combination of DDD for the domain layer and REPR for the API layer creates a robust foundation for complex business applications.

0
Subscribe to my newsletter

Read articles from Rick directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Rick
Rick

15+ years of experience having fun building apps with .NET I began my professional career in 2006, using Microsoft technologies where C# and Windows Forms and WPF were the first technologies I started working with during that time. I had the opportunity to actively participate in the Windows ecosystem as an MVP and Windows 8/Windows Phone application developer from 2013-2018. Throughout my career, I have used Azure as my default cloud platform and have primarily worked with technologies like ASP.NET Core for multiple companies globally across the US, UK, Korea, Japan, and Latin America. I have extensive experience with frameworks such as: ASP.NET Core Microsoft Orleans WPF UWP React with TypeScript Reactive Extensions Blazor I am an entrepreneur, speaker, and love traveling the world. I created this blog to share my experience with new generations and to publish all the technical resources that I had been writing privately, now made public as a contribution to enrich the ecosystem in which I have developed my career.