// Software · 6 min read

Building a CRM for a Trailer Company

On this trail
  1. The stack
  2. Postgres is the document store and the event store
  3. Endpoints as pure functions (mostly)
  4. One folder per operation
  5. Sharp edges
  6. The API contract is a file in git
  7. Audit log
  8. The frontend
  9. Ask Atlas
  10. Takeaways

The first commit on Atlas CRM is from Christmas Eve 2025.

Ten months later the repo has about 2,500 commits, 43 feature modules, and a bit over 300 HTTP endpoints, and it has been running in production since June. It tracks the whole life of a customer at Atlas Outdoors: someone fills out a form on the website, becomes a lead, gets a quote, signs an order, and eventually drives away with a trailer. Then the CRM keeps tracking that trailer, because trailers get sold, transferred, serviced, and sometimes show up years later with a new owner who wants to know what options it shipped with.

A CRM isn't necessarily the most exciting thing to build, but this one gave me a chance to use some patterns I'd been wanting to try. These are the parts I think other engineers might find useful.

The stack

  • Backend: .NET 10, Wolverine for HTTP and messaging, Marten on PostgreSQL for documents and event sourcing
  • Frontend: Vue 3.5, Vite, TanStack Query and TanStack Table, Shadcn-vue
  • Identity: Keycloak (OIDC), federated to Microsoft Entra for employees
  • Messaging: RabbitMQ
  • Ops: Docker Compose, Traefik, Serilog into Loki, Grafana on top
  • Neighbors: a Statamic public website (with a trailer configurator) and a separate commerce backend, all on the same message bus

No Entity Framework, no MVC controllers, no MediatR, and Postgres is the only database.

Postgres is the document store and the event store

Marten treats PostgreSQL as a document database. Your C# objects are stored as JSONB, you query them with LINQ, and you get real transactions and real indexes. You can still use psql to look at the data directly. It also does event sourcing in the same database, in the same transaction.

That meant I could decide per aggregate instead of for the whole system. Most things in the CRM (leads, accounts, contacts, quotes, tasks, the catalog) are plain documents with an embedded status history. They change, I save the new version, and an audit listener records who did it. Nothing fancy.

Two things are event-sourced, because they needed it:

  • Trailer. A trailer is planned, goes into production, lands in inventory or waits for delivery, gets delivered, and maybe gets transferred to a new owner or discarded. There are about thirty event types. When someone asks how a trailer ended up with this VIN and these options, the answer is the stream, read top to bottom.
  • OrderPaymentLedger. Payments, refunds, and the balance. Reconstructing how a balance got to be what it is from a single mutable Balance column doesn't work, so this one is a stream.

Both use inline snapshots, so reading the current state is one document load, and the full history is there when I need it. Everything else stays a plain document. Performance hasn't been a problem at Atlas's scale.

Endpoints as pure functions (mostly)

This is the pattern I'd bring to most any new project. Wolverine lets an endpoint be a static class with a few conventionally named methods, and it generates the glue code at build time. I split almost every endpoint into the same three pieces:

  1. LoadAsync does all the I/O: database reads, batched where possible.
  2. Validate checks business rules and returns ProblemDetails or "continue."
  3. Handle is a synchronous, pure function. It takes the loaded data, makes a decision, and returns what should happen.

Here's a real one, lightly trimmed:

C#
[Authorize]
public static class ClaimLeadEndpoint
{
    public static ProblemDetails Validate(Lead lead)
    {
        if (lead.IsConverted || lead.IsClosed)
            return new ProblemDetails { Detail = "Cannot claim a terminal lead", Status = 409 };

        if (lead.AssignedTo is not null)
            return new ProblemDetails { Detail = "Lead is already assigned", Status = 409 };

        return WolverineContinue.NoProblems;
    }

    [WolverinePost("/api/leads/{id:guid}/claim")]
    public static (LeadResponse, IMartenOp?, LeadClaimed?) Handle(
        [Entity(MaybeSoftDeleted = false)] Lead lead,
        ICurrentUser user)
    {
        lead.AssignTo(user.Id, user.Name);

        return (
            LeadMapper.ToResponse(lead),
            MartenOps.Store(lead),
            new LeadClaimed(lead.Id, lead.FullName, user.Id, user.Name)
        );
    }
}

Handle returns a response DTO, a database operation, and a domain event, but it doesn't perform any of them. Wolverine does, in one transaction, and the event goes through a transactional outbox so it isn't lost if the process dies right after.

The [Entity] attribute loads the lead by the route id and returns a 404 if it doesn't exist, so there's no null-check boilerplate. Unit testing Handle is just calling a static method with objects built in the test: no mocks, no HTTP, no database. I still have Alba integration tests that run the full pipeline against a real Postgres, but the business logic tests are fast.

The returned event is where side effects live. LeadCreated cascades to a handler that creates a follow-up task, and LeadClaimed cascades to notifications.

One folder per operation

Each HTTP operation gets its own folder with its endpoint, request, and validator:

Text
Features/
  Leads/
    CreateLead/
    ClaimLead/
    ConvertLead/
    ...
    Handlers/
    Shared/

When I need to change how claiming works, there's one place to look. When I delete a feature, I delete a folder. Forty-three modules in, I haven't had to really think about where new code goes.

Sharp edges

A few things cost me some mental cycles in my first Wolverine/Marten project.

Soft deletes behave three different ways. Marten's [SoftDeleted] attribute turns deletes into updates, and Query<T>() hides the deleted rows. But LoadAsync<T>(id) loads by id and ignores the soft-delete filter, and Wolverine's [Entity] attribute includes soft-deleted documents by default. That's three ways to load a document and two different defaults.

Conventions and code generation save a lot of work, but they also hide behavior like this. I run codegen preview after any endpoint change and read the generated code.

The API contract is a file in git

The backend generates an OpenAPI document, and that document is committed to the repo. The frontend's API client and TypeScript models are generated from it with Orval, and those are committed too. CI has two gates: the OpenAPI document must match what the backend actually produces, and the generated TypeScript must match the OpenAPI document.

If I change a DTO and forget to regenerate, CI fails. If I regenerate, the PR diff shows exactly how the contract changed, and TypeScript points at every component that needs updating. For this to work, endpoints return concrete DTOs rather than an opaque IResult, so the generated types mean something.

Audit log

Every entity change gets an audit entry. A Marten session listener runs right before SaveChanges, looks at the pending changes, and writes an AuditLogEntry for each one in the same transaction. A lead can't change without an audit record, and there's never an audit record for a change that rolled back.

The frontend

Vue 3 with TanStack Query for server state, TanStack Table for tables, and Shadcn-vue for components. MSW mocks the API in tests, and the tests drive real composables and real components rather than shallow stubs.

Ask Atlas

The newest part is an in-app assistant called Ask Atlas. It's Claude Haiku 4.5 (soon to be 5.5) with a set of tools that call into the CRM, such as searching records, pulling up an account, reading an account's timeline, and listing leads. A salesperson can ask "what's going on with the Hendersons?" and get an answer based on the actual data.

It also answers "how do I..." questions from a help manifest generated from the app's help content. That manifest goes stale as soon as someone edits the help content without regenerating it, so CI now fails the build when they don't match. If you build something like this, check the model's reference material in CI the same way you'd check generated code.

Takeaways

  • Pure handlers are worth it. The Wolverine "way" felt odd to me at first, but I quickly adapted to it. I don't hate working in this codebase.
  • Event-source the things that have a history. Trailers and money do. A contact's phone number doesn't. Deciding per aggregate works better than deciding for the whole system.

Oh, and Atlas Outdoors builds some awesome overlanding adventure trailers. Please check them out if you're in the market.

Field reports

No reports yet.

    Leave a report