Guexit
Guexit is a guessing game for the browser, inspired by board games like Dixit. Each round, the storyteller tells a story about a card from their hand, everyone else plays the card that best fits it, and then they all vote for the storyteller’s. Every card is AI-generated art.
It’s played from a phone, so there’s nothing to install, and whatever one player does has to show up on everyone else’s screen straight away.
Architecture
Guexit is a modular monolith: one application, deployed as a single unit, split into modules with clear boundaries. The Game module owns the rules and the game rooms, and the Identity module signs players in with Google, Discord or Twitch, or as guests. Commands go through the domain model, and queries read from dedicated read models.
flowchart TB
players["Players' browsers<br/>Angular"]
workers["AI workers"]
app["Modular monolith<br/>ASP.NET Core · SignalR"]
storage[("Card images<br/>Blob storage")]
db[("Database<br/>PostgreSQL")]
telemetry["Observability<br/>Grafana Cloud"]
players <-->|"HTTPS · WebSockets"| app
workers -->|upload| storage
workers -->|"HTTP · add image"| app
app --> db
app -->|OpenTelemetry| telemetry
The card art comes from AI workers that run outside the app. Each worker uploads the image it generates to blob storage, then calls the image endpoint over HTTP, authenticated with an API key, to add it with its URL and tags. The tags decide which images can end up in a deck, and adding an image goes through the same command pipeline as any move in the game.
It all runs with Docker Compose on a single Hetzner VM, behind Cloudflare and Caddy, and the pipeline builds, tests and deploys every change to it.
Command handlers that read like the domain
This is all the code that runs when a player votes for a card:
public sealed class VoteCardCommandHandler : ICommandHandler<VoteCardCommand>
{
private readonly IGameRoomRepository _gameRoomRepository;
public VoteCardCommandHandler(IGameRoomRepository gameRoomRepository)
{
_gameRoomRepository = gameRoomRepository;
}
public async ValueTask<Unit> Handle(VoteCardCommand command, CancellationToken ct)
{
var gameRoom = await _gameRoomRepository.GetBy(command.GameRoomId, ct)
?? throw new GameRoomNotFoundException(command.GameRoomId);
gameRoom.VoteCard(command.VotingPlayerId, command.SubmittedCardId);
return Unit.Value;
}
}
There’s no saving, no transaction and no notification code, and that’s thanks to the unit of work pattern. Loading an aggregate through a repository registers it with the unit of work, which keeps track of its state from then on. A pipeline around every command opens one transaction, lets the handler use as many repositories as it needs, and commits once at the end, even when a command spans modules. The changes reach the database in a single batch, so none of this adds round trips or penalises performance.
Real-time updates with domain events
The domain model doesn’t know about SignalR either. VoteCard records a GuessingPlayerVoted domain event, and when the last vote comes in, another one with the round’s scores. When the unit of work commits, it publishes those events, and a handler in the web layer turns each one into a SignalR message for the players in that game room.
The messages wait until the transaction has committed, so no player ever sees a move that was rolled back.
One writer per game room
Several players act on the same game room at once, often within milliseconds of each other. Every command for a game room takes a PostgreSQL advisory lock on that room, so its commands run one at a time, and an optimistic concurrency check on the aggregate backs that up.
Testing strategy
Most tests run a command handler against in-memory repositories. Builders put a game room into any state in a line or two, and each test asserts one behaviour, like RoundScoreIsCalculated. They run in milliseconds and don’t depend on how the aggregate is stored, so the model can be refactored freely.
Persistence tests run against a real PostgreSQL to pin down the mappings and queries. Component tests start the whole application in memory and drive it over HTTP as a signed-in player, against the same real database.
The pipeline runs the unit tests first, then applies the real migrations to a fresh PostgreSQL before running the integration tests, so every change also proves its migrations.