DEV Community

Cover image for What I Learned Building a Production-Style Shopping List App with Angular and ASP.NET Core (CQRS)
magmablinker
magmablinker

Posted on

What I Learned Building a Production-Style Shopping List App with Angular and ASP.NET Core (CQRS)

What I Learned Building a Production-Style Shopping List App with Angular and ASP.NET Core (CQRS)
Shoppi Landing PageA small shopping list app turned into a real-world testbed for authentication, real-time updates, observability, CQRS, deployment, and my own .NET library, Axent (see https://medium.com/@magmablinker/source-generated-cqrs-in-net-meet-axent-b86e61dba3e5).

Intro

Shopping list apps are usually not considered exciting engineering projects.
At first glance, Shoppi is exactly that:
a small web app for creating and sharing shopping lists. You can add products, organize them by category or brand, mark items as completed, and collaborate with other people in real time.
But I intentionally built Shoppi like a real production application.
Not because every shopping list app needs a full observability stack, CQRS, audit logs, background jobs, Docker-based deployment, and real-time synchronization. It probably does not.
I built it this way because I wanted a real project that would force me to make realistic architectural decisions instead of only building isolated demos.
Shoppi became the place where I tested ideas around Angular, ASP.NET Core, PostgreSQL, SignalR, OpenTelemetry, and eventually Axent, my source-generated CQRS library for .NET.
This post is about what I built, which decisions worked well, where the complexity was worth it, and where I would simplify things if I started again.

What Shoppi is

Shoppi is a collaborative shopping list app.
The core idea is simple:

  • create shopping lists
  • add products
  • group products by category or brand
  • mark items as completed
  • share lists with other users via workspace
  • sync changes in real time
  • use the app comfortably on mobile

The project is publicly deployed at shoppi.club, with a separate status page for monitoring availability.

Shopping List Details UI

From a product perspective, it is intentionally small. From an engineering perspective, I treated it as if it had to survive real usage, real deployments, and real debugging sessions.
That changed many decisions.

The stack

Shoppi currently uses:
Frontend:

  • Angular
  • Angular Material
  • Tailwind CSS
  • PWA support

Backend:

  • ASP.NET Core
  • PostgreSQL
  • Entity Framework Core
  • SignalR
  • Axent for CQRS-style request handling

Infrastructure:

  • Docker Compose
  • Hetzner VPS
  • Cloudflare Pages for the frontend
  • Cloudflare in front of the domain
  • Caddy as reverse proxy

Observability:

  • OpenTelemetry
  • Prometheus
  • Grafana
  • Loki
  • Tempo

Angular is the frontend framework I used for the SPA, and ASP.NET Core SignalR fits the real-time part well because it is designed for server-side code pushing updates to connected clients.
The backend is built around ASP.NET Core APIs. Minimal APIs are a natural fit for endpoint-first HTTP APIs, but as the project grew, I did not want all application logic to live inside endpoint handlers.
Microsoft's Minimal API docs describe them as an alternative to controller-based APIs, which matched the style I wanted, but I still needed structure behind the endpoints.
That is where CQRS entered the project.

Why I did not keep everything as simple CRUD

The first version of an app like this can be extremely simple:

POST /shopping-lists
GET /shopping-lists
POST /shopping-lists/{id}/items
PATCH /shopping-lists/{id}/items/{itemId}
DELETE /shopping-lists/{id}/items/{itemId}
Enter fullscreen mode Exit fullscreen mode

At the beginning, it is tempting to put most logic directly into endpoints or services.
That works for a while.
But Shoppi slowly accumulated cross-cutting concerns:

  • validation
  • authorization
  • transaction handling
  • response mapping
  • audit logging
  • real-time SignalR notifications
  • error handling
  • metrics and traces
  • background jobs
  • external promotion scraping
  • admin-style observability

None of these concerns are huge individually. The problem is repetition.
For example, creating a shopping list is not just "insert row into database." It can involve validating the request, checking the current user, creating related data, saving changes, returning a consistent response, logging useful information, and notifying other clients.
The more features I added, the more I wanted every use case to follow a predictable shape.
That led me to a CQRS-style application layer.

The application layer shape

In Shoppi, I started thinking less in terms of controllers or endpoints and more in terms of use cases.
Examples:

CreateShoppingListCommand
DeleteShoppingListCommand
AddShoppingListItemCommand
UpdateShoppingListItemCommand
GetShoppingListsQuery
GetShoppingListDetailsQuery
GetProductsQuery
Enter fullscreen mode Exit fullscreen mode

Each command or query represents one specific action.
The endpoint becomes thin:

var shoppingListGroup = builder.MapGroup("shopping-lists")
 .RequireAuthorization()
 .WithTags(nameof(ShoppingList));

shoppingListGroup.MapPost(string.Empty,
  async ([FromBody] CreateShoppingListRequest request, ISender sender, CancellationToken cancellationToken) =>
  {
   var response = await sender.SendAsync(new CreateShoppingListCommand { Name = request.Name }, cancellationToken);
   return response.ToResult();
  })
 .Produces<long>()
 .Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
 .WithName("createShoppingList");
Enter fullscreen mode Exit fullscreen mode

The handler contains the use-case logic:

[Authorize(Policy = nameof(MustBeWorkspaceMemberCommandRequirement))]
public sealed class CreateShoppingListCommand : ICommand<long>
{
    public required string Name { get; init; }
}

public sealed class CreateShoppingListCommandValidator : AbstractValidator<CreateShoppingListCommand>
{
    public CreateShoppingListCommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(EntityConstraints.ShoppingList.NameMaxLength);
    }
}

internal sealed class CreateShoppingListCommandHandler : IRequestHandler<CreateShoppingListCommand, long>
{
    private const string _typeName = nameof(CreateShoppingListCommandHandler);

    private readonly IHubClientContext _hubClientContext;
    private readonly ShoppingListManager _manager;
    private readonly IWorkspaceContext _workspaceContext;

    public CreateShoppingListCommandHandler(ShoppingListManager manager,
        IHubClientContext hubClientContext,
        IWorkspaceContext workspaceContext)
    {
        _manager = manager;
        _hubClientContext = hubClientContext;
        _workspaceContext = workspaceContext;
    }

    public async ValueTask<Response<long>> HandleAsync(RequestContext<CreateShoppingListCommand> context,
        CancellationToken cancellationToken = default)
    {
        using var activity = ShoppiTelemetry.Source.StartActivity($"{_typeName}.{nameof(HandleAsync)}");

        var workspaceId = _workspaceContext.WorkspaceId;
        var entity = ShoppingList.Create(context.Request.Name, workspaceId);
        var result = await _manager.CreateAsync(entity, cancellationToken);
        if (result.IsFailure) return Response.Failure(result.Error);

        await _hubClientContext.NotifyListChanged(workspaceId,
        () => new()
        {
            Id = entity.Id.Value,
            Name = entity.Name,
            IsCompleted = false
        });

        return Response.Success(entity.Id.Value);
    }
}
Enter fullscreen mode Exit fullscreen mode

The authorization is being handled by an Axent Pipeline + aspnet.core authorization

public sealed class MustBeWorkspaceMemberCommandRequirement : IAuthorizationRequirement;

public sealed class MustBeWorkspaceMemberCommandRequirementHandler
    : AuthorizationHandler<MustBeWorkspaceMemberCommandRequirement>
{
    private const string _typeName = nameof(MustBeWorkspaceMemberCommandRequirementHandler);

    private readonly WorkspaceManager _manager;
    private readonly IWorkspaceContext _workspaceContext;

    public MustBeWorkspaceMemberCommandRequirementHandler(WorkspaceManager manager, IWorkspaceContext workspaceContext)
    {
        _manager = manager;
        _workspaceContext = workspaceContext;
    }

    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MustBeWorkspaceMemberCommandRequirement requirement)
    {
        using var activity = ShoppiTelemetry.Source.StartActivity($"{_typeName}.{nameof(HandleRequirementAsync)}");
        activity?.SetTag(ActivityTags.UserId, _workspaceContext.UserId);
        activity?.SetTag(ActivityTags.WorkspaceId, _workspaceContext.WorkspaceId.Value);

        var canAccess = await _manager.CanAccessAsync(_workspaceContext.WorkspaceId, _workspaceContext.UserId, CancellationToken.None);
        if (canAccess)
        {
            context.Succeed(requirement);
            activity?.SetStatus(ActivityStatusCode.Ok);
        }
        else
        {
            activity?.SetStatus(ActivityStatusCode.Error, "Unauthorized");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This structure is more verbose than putting everything in one service. But the benefit is consistency.
When I open a feature, I know where to look:

ShoppingLists
   ├───Commands
   │       AddShoppingListItemCommand.cs
   │       CreateShoppingListCommand.cs
   │       DeleteShoppingListCommand.cs
   │       DeleteShoppingListItemCommand.cs
   │       ReorderShoppingListItemsCommand.cs
   │       UpdateShoppingListItemCommand.cs
   │
   └───Queries
           GetShoppingListByIdQuery.cs
           GetShoppingListsQuery.cs
Enter fullscreen mode Exit fullscreen mode

That predictability matters more as the app grows.

Where Axent came from

Shoppi is also where I dogfood Axent.
Axent is my source-generated CQRS library for .NET. It provides request dispatching, command/query separation, typed pipelines, and ASP.NET Core integration. The public repository describes it as a source-generated CQRS library for modern .NET with typed pipelines and ASP.NET Core integration.
I did not create Axent because Shoppi needed "a framework."
I created it because I wanted a small application-layer tool that matched how I was already building the backend:

  • explicit commands and queries
  • no runtime reflection-heavy dispatch
  • predictable request pipelines
  • easy ASP.NET Core integration
  • small mental model
  • enough structure without adopting a huge framework

A typical request flow in Shoppi looks roughly like this:

HTTP endpoint
  -> command/query
    -> Axent dispatch
      -> pipelines
        -> handler
          -> database / domain logic / SignalR notification
      -> response
  -> HTTP result
Enter fullscreen mode Exit fullscreen mode

Example: completing a shopping list item

A good example is completing an item.

From the UI, this looks tiny. The user taps a checkbox.
But the backend still has to answer several questions:

  • Does the item exist?
  • Does the current user have access to the list?
  • Should the list state change?
  • Do other connected clients need to be notified?
  • Should the action be audited?
  • What should the API return?

The endpoint should not know all of that.

The command can express the intent:

[Authorize(Policy = nameof(MustBeWorkspaceMemberCommandRequirement))]
public sealed class UpdateShoppingListItemCommand : ICommand<Unit>
{
    public required ShoppingListItemId Id { get; init; }
    public required bool IsCompleted { get; init; }
    public required ShoppingListItemUnit Unit { get; init; }
    public required short Quantity { get; init; }
    public required int Position { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

The validator owns validation logic:

public sealed class UpdateShoppingListItemCommandValidator : AbstractValidator<UpdateShoppingListItemCommand>
{
    public UpdateShoppingListItemCommandValidator()
    {
        RuleFor(x => x.Unit)
            .NotEqual(ShoppingListItemUnit.Unknown);
    }
}
Enter fullscreen mode Exit fullscreen mode

The handler owns the use case orchestration:

internal sealed class UpdateShoppingListItemCommandHandler : IRequestHandler<UpdateShoppingListItemCommand, Unit>
{
    private const string _typeName = nameof(UpdateShoppingListItemCommandHandler);

    private readonly IHubClientContext _hubClientContext;
    private readonly ShoppingListManager _manager;
    private readonly IWorkspaceContext _workspaceContext;

    public UpdateShoppingListItemCommandHandler(ShoppingListManager manager,
        IHubClientContext hubClientContext,
        IWorkspaceContext workspaceContext)
    {
        _manager = manager;
        _hubClientContext = hubClientContext;
        _workspaceContext = workspaceContext;
    }

    public async ValueTask<Response<Unit>> HandleAsync(RequestContext<UpdateShoppingListItemCommand> context,
        CancellationToken cancellationToken = default)
    {
        using var activity = ShoppiTelemetry.Source.StartActivity($"{_typeName}.{nameof(HandleAsync)}");

        var request = context.Request;
        activity?.SetTag(ActivityTags.ShoppingListItemId, request.Id.Value);

        var itemResponse = await _manager.FindItemAsync(request.Id, cancellationToken);
        if (itemResponse.IsFailure) return Response.Failure<Unit>(itemResponse.Error);

        var item = itemResponse.Value;

        var workspaceId = _workspaceContext.WorkspaceId;
        var shoppingListResponse = await _manager.FindAsync(item.ShoppingList.Id, workspaceId, cancellationToken);
        if (shoppingListResponse.IsFailure) return Response.Failure(shoppingListResponse.Error);

        item.SetCompleted(request.IsCompleted);
        item.SetUnit(request.Unit);
        item.SetQuantity(request.Quantity);
        item.SetPosition(request.Position);

        var response = await _manager.UpdateItemAsync(item, cancellationToken);
        if (response.IsFailure) return Response.Failure<Unit>(response.Error);

        await _hubClientContext.NotifyItemChanged(itemResponse.Value.ShoppingList.Id,
            () => new()
            {
                Id = item.Id.Value,
                Position = item.Position,
                Quantity = item.Quantity,
                Unit = item.Unit,
                IsCompleted = item.IsCompleted,
                Product = item.Product.ToResponse()
            });

        var shoppingList = shoppingListResponse.Value;
        await _hubClientContext.NotifyListChanged(workspaceId,
            () => new()
            {
                Id = shoppingList.Id.Value,
                Name = shoppingList.Name,
                IsCompleted = shoppingList.Items.All(i => i.IsCompleted)
            });

        return Response.Success(Unit.Value);
    }
}
Enter fullscreen mode Exit fullscreen mode

Axent handles the boring but important parts (authorization, validation, tracing, transactions):

private WebApplicationBuilder ConfigureAxent()
{
    var applicationAssembly = typeof(GetShoppingListByIdQuery).Assembly;

    builder.Services.AddAxent(o => builder.Configuration.Bind("Axent", o), [applicationAssembly])
        .AddAuthorization()
        .AddTracing()
        .AddRequestHandlersFromAssembly(applicationAssembly)
        .AddAutoFluentValidation()
        .AddCache();

    builder.Services.AddValidatorsFromAssembly(applicationAssembly);

    return builder;
}
Enter fullscreen mode Exit fullscreen mode

This is where CQRS feels useful to me. Not because it makes the simple case shorter. It does not. It makes the growing case more consistent.

Real-time updates with SignalR

Real-time updates are one of the features that made Shoppi feel like an actual app instead of a CRUD demo.

If two people have the same list open and one person adds or completes an item, the other person should see the change without refreshing.

SignalR is a good fit for this because ASP.NET Core SignalR is specifically designed to simplify real-time web functionality and let server-side code push updates to clients instantly.
In Shoppi, this means a command handler can finish a use case and then publish a real-time event.

Example flow:

User completes item
  -> Angular sends request to API
  -> handler updates database entries
  -> transaction commits
  -> API broadcasts update through SignalR
  -> other clients update their UI
Enter fullscreen mode Exit fullscreen mode
// UpdateShoppingListItemCommand.cs
await _hubClientContext.NotifyItemChanged(itemResponse.Value.ShoppingList.Id,
 () => new()
 {
  Id = item.Id.Value,
  Position = item.Position,
  Quantity = item.Quantity,
  Unit = item.Unit,
  IsCompleted = item.IsCompleted,
  Product = item.Product.ToResponse()
 });
Enter fullscreen mode Exit fullscreen mode
// HubClientContext.cs
public async Task NotifyItemChanged(ShoppingListId id, Func<ShoppingListItemResponse> item) =>
    await _shoppingListItemsContext.Clients
        .Groups(GroupHelper.GetShoppingListDetailGroupName(id))
        .NotifyItemChanged(item());
Enter fullscreen mode Exit fullscreen mode

The tricky part is deciding where real-time notifications belong.
Putting them directly into endpoints makes the endpoint too smart.
Putting them randomly into services makes the flow hard to follow.
For Shoppi, command handlers are a reasonable place because the handler already represents the completed use case.
That said, this is one area I would continue to refine. For larger systems, I would probably move more of this toward domain events or integration events.

Observability: why I added it early

For a small app, observability can look unnecessary.
But I wanted Shoppi to answer basic production questions:

  • Is the API healthy?
  • Which requests are slow?
  • Are errors increasing?
  • What happened before this exception?
  • Is the database the bottleneck?
  • Are background jobs failing?

That is why I added OpenTelemetry and a Grafana-based stack.
Microsoft describes observability as using telemetry like logs, metrics, and distributed traces to understand app performance and diagnose problems. It also describes logs, metrics, and distributed tracing as the three common pillars of observability.
OpenTelemetry fits this because it is a cross-platform standard for collecting and exporting telemetry data, and Microsoft's .NET docs describe how .NET integrates with logging, metrics, and tracing APIs such as ILogger, Meter, and ActivitySource.
For Shoppi, this was useful even with a small user base.

I could see:

  • slow API requests
  • external calls
  • database-heavy operations
  • background job behavior
  • request traces
  • structured logs

Example Trace

This helped me treat performance and debugging as part of the project, not an afterthought.

Deployment setup

Shoppi is deployed with a simple but practical setup that keeps costs low and efficiency high:

Frontend:
Angular SPA hosted on Cloudflare Pages

Backend:
ASP.NET Core API running in Docker on a Hetzner VPS served via Caddy 
reverse proxy

Database:
PostgreSQL running via Docker Compose on VPS, inaccessible from 
outside network.

Networking:
Cloudflare in front of the public domain

Monitoring:
Separate observability stack

CI/CD: 
Frontend deployed automatically via Cloudflare Workers
Backend deployed via GitHub action + deployment script
Enter fullscreen mode Exit fullscreen mode

This is not enterprise infrastructure, but it is real enough to expose the problems you do not see in local development:

  • environment variables
  • HTTPS and reverse proxy behavior
  • container startup order
  • database volumes
  • deployment cleanup
  • logs
  • health checks
  • status page monitoring
  • CORS and authentication redirects
  • SPA routing and deep links

Simplified docker compose example:

services:
  postgres:
    image: postgres:16-alpine
    container_name: shoppi-postgres
    restart: unless-stopped
    networks:
      - shoppi
    healthcheck:
      test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ]
      interval: 10s
      timeout: 5s
      retries: 5

  api:
    build:
      context: .
      dockerfile: ./src/Dockerfile
    container_name: shoppi-api
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - shoppi
      - web

  caddy:
    image: caddy:2
    container_name: shoppi-caddy
    restart: unless-stopped
    depends_on:
      - api
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - shoppi
      - web

volumes:
  postgres_data:
  caddy_data:
  caddy_config:

networks:
  shoppi:
  web:
    external: true
Enter fullscreen mode Exit fullscreen mode

The biggest lesson here was that deployment is part of the product.
A feature is not really done when it works locally. It is done when it can be deployed, observed, debugged, and recovered.

What worked well

  1. CQRS made the backend easier to navigate Once the app had more than a few endpoints, the command/query structure helped. I like being able to search for a use case by name and immediately find the request, handler, validator, and tests. That is boring in a good way.
  2. Pipelines reduced repeated code Validation, transactions, logging, and response mapping are exactly the kind of things that get duplicated everywhere. Using request pipelines made those concerns more consistent.
  3. SignalR made the app feel alive Real-time updates matter for collaborative shopping lists. Without them, shared lists feel stale. SignalR added complexity, but it also made the product noticeably better.
  4. Observability paid off earlier than expected Even with a small app, traces and logs helped me understand what was actually happening. This was especially useful for authentication, external calls, scraping, and slower requests.
  5. Dogfooding Axent exposed real library problems Using Axent in Shoppi forced me to care about things a sample project would not reveal:
  6. API ergonomics
  7. handler discovery
  8. pipeline ordering
  9. response mapping
  10. package boundaries
  11. ASP.NET Core integration
  12. documentation gaps
  13. testing experience

That made Axent better than it would have been as a standalone experiment.

What added complexity

  1. CQRS creates more files This is the obvious tradeoff. For tiny features, CQRS can feel like too much ceremony. A simple endpoint can become:
Command
Handler
Validator
Response
Endpoint mapping
Tests
Enter fullscreen mode Exit fullscreen mode

That is not always worth it.
For Shoppi, it became worth it once the same cross-cutting concerns appeared again and again. But I would not recommend this structure for every small app.

  1. Real-time synchronization creates edge cases Once multiple clients can update the same list, you have to think about: stale UI state optimistic updates reconnect behavior duplicate events event ordering authorization for hub connections when to broadcast and when not to

This is manageable, but it is not free.

  1. Observability has its own operational cost
    Running Grafana, Prometheus, Loki, Tempo, and OpenTelemetry exporters is valuable, but it also adds infrastructure to maintain.
    For a small hobby app, hosted monitoring might be simpler. I chose this setup because I wanted to learn and control the stack.

  2. Deployment details consume surprising time
    Docker, reverse proxies, Cloudflare, TLS, container cleanup, database volumes, and health checks are not glamorous.
    But they matter.
    A project that is actually online will always teach you more than a project that only runs on localhost.

What I would simplify if I started again

If I started Shoppi again, I would still use Angular, ASP.NET Core, PostgreSQL, SignalR, and a structured application layer.
But I would simplify a few things earlier:

  1. Start with clearer boundaries Some features grew organically. I would define feature boundaries earlier:
ShoppingLists
Products
Categories
Brands
Users
Promotions
AuditLogs
Enter fullscreen mode Exit fullscreen mode
  1. Add observability gradually
    I like the current observability setup, but I would introduce it in stages:
    Stage 1: structured logs
    Stage 2: traces
    Stage 3: metrics
    Stage 4: dashboards and alerting

  2. Keep CQRS pragmatic
    Not every operation needs a perfect abstraction.
    For some internal admin features or tiny read endpoints, a simpler query path may be enough.

  3. Treat mobile UX as a first-class feature from day one
    A shopping list app is mostly used on a phone.
    That means touch behavior, scrolling, swipe actions, installability, offline behavior, and input focus matter more than they would in a desktop-first app.

Was CQRS worth it?

For Shoppi, yes.
But not because CQRS magically makes code better.
CQRS was worth it because Shoppi had enough repeated application-layer behavior:

validate input
authorize user
load data
apply use case
save changes
audit action
notify clients
map response
trace execution
Enter fullscreen mode Exit fullscreen mode

When those steps exist across many features, a consistent command/query model starts to pay off.
If Shoppi were only a tiny CRUD app with five endpoints, I would probably use plain services.
That is the important distinction.
CQRS is not valuable because it is "clean architecture." It is valuable when it makes real use cases easier to understand, test, and evolve.

How Axent fits into the bigger picture

Axent is not the reason Shoppi works.
Shoppi would work with plain services, MediatR, Wolverine, FastEndpoints, or a custom dispatcher.
But Axent fits the style I wanted:

explicit
source-generated
typed
ASP.NET Core-friendly
pipeline-oriented
Enter fullscreen mode Exit fullscreen mode

The NuGet package ecosystem currently includes Axent core packages and extension packages for ASP.NET Core, FluentValidation, Authorization, Caching, and templates.
For me, Shoppi is the proof that Axent is not just a benchmark project. It is used in a real deployed app with real request flows, real deployment constraints, and real debugging needs.
That does not mean everyone should use it.
It means the library came from actual usage, not only from wanting to build "another mediator."

Lessons learned

  1. Small domains can still produce real engineering problems
    Shopping lists are simple.
    Shared shopping lists with auth, realtime sync, mobile UX, audit logs, deployment, and observability are not as simple.

  2. Architecture should appear when repetition appears
    I did not need every abstraction on day one.
    The useful abstractions became obvious when I started repeating the same patterns.

  3. A deployed side project teaches more than a perfect local demo
    The hardest parts were not always the code.
    Often they were deployment, monitoring, routing, authentication redirects, mobile behavior, and operational cleanup.

  4. Dogfooding a library is uncomfortable but valuable
    Using Axent in Shoppi forced me to notice awkward APIs, missing docs, unclear package boundaries, and real integration issues.
    That is exactly what a library needs before other people can trust it.

  5. "Production-style" does not mean "enterprise-scale"
    Shoppi is not a huge system.
    But it has enough real-world concerns to be a useful testbed.
    That is the sweet spot for learning.

Conclusion

Shoppi started as a shopping list app, but it became much more valuable as an engineering project.
It gave me a real place to test frontend UX, backend architecture, real-time updates, deployment, observability, and CQRS patterns. It also gave me the motivation to build and improve Axent in a real application instead of designing it only around artificial examples.
The biggest lesson is that architecture only matters when it helps with actual problems.
For Shoppi, CQRS and Axent helped because the app had repeated use cases, cross-cutting concerns, and enough backend behavior to justify a consistent application layer.
For a smaller app, I would use less.
For this app, the structure has been worth it.

Links

Live app:
https://shoppi.club/
Status page:
https://status.shoppi.club/status/shoppi
Axent GitHub:
https://github.com/magmablinker/Axent
Axent NuGet:
https://www.nuget.org/packages/Axent.Core
Previous Axent article:
https://medium.com/@magmablinker/source-generated-cqrs-in-net-meet-axent-b86e61dba3e5

Thanks for reading!

If you are building ASP.NET Core APIs and like the idea of explicit commands, queries, and typed pipelines, Axent might be worth checking out.
It is still small, and I am actively improving it based on real usage in Shoppi.
Feedback, issues, and small sample projects are very welcome.

Top comments (0)