Skip to content

From CRUD to a planning flow

The tournament app’s first domain vertical slice was deliberately small: manage players.

That was the right entry point. A CRUD feature is well suited to proving out the architecture end to end: frontend, API gateway, service, database, auth context, and club/tenant context.

With tournament planning, the next step arrived.

This time it wasn’t just one form and one list anymore. It was a multi-step domain flow:

Create tournament
Select players
Form groups
Generate schedule
Complete planning

That’s what turned the project into more than a CRUD example for the first time.

The players slice had shown that the architecture holds up in principle.

The tournament setup flow showed where it gets harder: derived state, wizard state, routing context, persistence boundaries, and reactivity across multiple steps.

Tournament planning was deliberately not built into player management.

Players are their own domain area. Tournament setup is a different one.

So a new vertical area got created:

libs/tournament/tournament-setup/model
libs/tournament/tournament-setup/domain
libs/tournament/tournament-setup/presentation

The API gateway and service got organized around the same domain boundary too:

apps/apis/tournament-api/src/app/tournament-setup
apps/services/tournament-service/src/app/tournament-setup

The goal was clear:

Setup is allowed to use players, but must not become dependent on the players frontend.

Tournament planning loads its own view of active players. It doesn’t reach into a PlayersStore. That’s deliberately a bit more effort, but it protects the domain boundary.

A player in player management isn’t automatically the same ViewModel as a selectable tournament participant.

The use case currently consists of three steps:

1. Select players
2. Form groups
3. Generate schedule

The entry point sits on the tournament setup overview.

From there, you can either start a new tournament or continue working on an existing open one. As soon as a specific tournament is open, you’re inside the tournament context.

This separation mattered more than it seemed to at first.

/tournament-setup means:

Overview

A specific tournament means:

/tournament-setup/:tournamentId/players
/tournament-setup/:tournamentId/groups
/tournament-setup/:tournamentId/schedule

Only through this separation did it become clear when the app should show a list of existing tournaments and when it’s inside an active planning process.

In the first step, active players get displayed and selected.

Player selection is deliberately not access to the existing player management. Setup gets its own API view:

GET /tournament-setup/available-players

The UI shows the players in a grid. Selected players are clearly marked.

The validation is deliberately simple:

at least 4 players
an even number of players

It’s not decided yet, at this point, who actually plays and who stays a reserve. That happens during group formation.

In the second step, the selected players get assigned to groups.

Group formation allows for a player pool. Not every selected player has to be assigned to a group. Players who stay in the pool get persisted as reserves later.

For the MVP, the plannable group sizes were deliberately limited:

4 players
6 players
8 players
10 players

That’s an important simplification.

Instead of building a general rule set for every conceivable group size right away, only what the current tournament format actually needs gets supported.

Players may remain in the pool. But groups themselves need to have a supported size.

Valid: 4, 6, 8, or 10 players per group
Invalid: 2, 5, 12, or other sizes

The third step generates the schedule for the first group phase.

As soon as the schedule tab gets opened, the backend checks:

Does a schedule already exist?

If yes, it gets loaded.

If not, and the tournament has the GROUPS_READY status, the schedule gets generated and persisted. After that, the status gets set to SCHEDULE_READY.

That completes the planning.

Important: in the MVP, there’s no redraw and no manual editing of the schedule.

That’s intentional.

A schedule shouldn’t get redrawn arbitrarily until someone likes it better. Editability or new draws can become their own use case later. For the MVP, the schedule stays stable.

Round 2 sounds similar at first glance.

Groups again. Doubles again. Pairings again.

From a domain perspective, however, it is different.

In round 1, the players are known. They come from group formation.

In round 2, the players only emerge from results.

Top 4 from group A
Top 4 from group B

or a comparable qualification model.

That means round 2 can’t be cleanly planned before results and standings exist.

That’s why tournament planning currently ends at:

SCHEDULE_READY

Tournament day later begins with:

ACTIVE

That’s where result recording, standings, qualification, and round 2 belong.

Rule set for the first group phase

For the MVP, the schedule generator supports four group sizes:

4 players
6 players
8 players
10 players

The planned number of games per player:

4 players → 1 game per player
6 players → 2 games per player
8 players → 3 games per player
10 players → 4 games per player

Since it’s always doubles, a match consists of four players:

2 players against 2 players

That gives the following match counts:

4 players:
1 round
1 match
no bye
6 players:
3 rounds
1 match per round
2 players get a bye per round
8 players:
3 rounds
2 matches per round
no bye
10 players:
5 rounds
2 matches per round
2 players get a bye per round

For a group of 10, that means:

10 players × 4 games per player = 40 player appearances
1 doubles match = 4 player appearances
40 / 4 = 10 matches

Which gives:

5 rounds × 2 matches per round = 10 matches

Quality goals:

The number of games per player checks out.
Byes are distributed fairly.
Partner pairings repeat as rarely as possible.
Opponent pairings repeat as rarely as possible.
Reserve players don't get scheduled.

In the MVP, the generator works with fixed, testable templates for the supported group sizes. Later, this could turn into a configurable rule set or a real generator.

Design discovery: a screenshot isn’t enough

Section titled “Design discovery: a screenshot isn’t enough”

The visual part turned out to be surprisingly involved.

At the start was a design image for inspiration: a dark interface, club colors, black and green jerseys, an athletic admin UI.

For a human, the direction was immediately clear.

Not for the coding agent.

It could roughly tell from the image that it should be dark, green, and card-based. But it repeatedly missed the actual structure.

Typical problems:

cards inside cards
restless meta areas
wrong hover effects
jumping borders
white default surfaces in a dark UI
imprecise spacing
inconsistent arrangement

The takeaway was clear:

For the agent, a screenshot is a moodboard, not a layout contract.

A classic grid system worked better.

Instead of “make it look like the screenshot,” prompts worked better when they described the layout explicitly.

For example:

12-column grid
Desktop:
- Group area: col-span-9
- Side panel: col-span-3
Tablet/Mobile:
- Group area: col-span-12
- Side panel: col-span-12

Or for player cards:

Desktop:
- 4 cards per row
- each card col-span-3
Tablet:
- 2 cards per row
- each card col-span-6
Mobile:
- 1 card per row
- each card col-span-12

That was far more tangible than a purely visual target.

Flexbox is powerful, but often too open-ended in prompts. An agent then interprets a lot on its own. A grid with columns, areas, and breakpoints is more verifiable in comparison.

So the new rule for layout prompts is:

Images provide mood. The 12-column grid provides the contract.

Small layout contracts instead of big wishes

Section titled “Small layout contracts instead of big wishes”

A good layout prompt doesn’t just describe how something should feel — it describes where it sits.

Bad:

Make the list more professional.

Better:

Player row uses a 12-column grid.
Avatar on the left.
Name and ranking in the main area.
Status on the right.
Actions all the way to the right.
No nested cards.
No hover effects on non-clickable rows.

This kind of instruction is less poetic, but much more effective.

That matters especially with AI agents. They don’t just need direction — they need hard boundaries.

Drift: when familiar patterns work against modern architecture

Section titled “Drift: when familiar patterns work against modern architecture”

The next big lesson was architecture drift.

The app uses modern Angular approaches with signals, stores, derived state, and increasingly resource-based data flows too.

But the agent repeatedly fell back into older patterns.

That was especially visible when loading data.

Instead of using a reactive data flow, it repeatedly wanted to build a classic initial load:

Component starts
→ ngOnInit
→ load()
→ write data into local state

That’s a pattern you find in a great many Angular codebases. It’s not fundamentally wrong. But it doesn’t always fit modern signal-based architecture.

My hypothesis: a large share of the training material and community examples still relies on these older imperative set-and-patch patterns.

So:

load
set
patch
sync manually

Modern Angular APIs like resource or httpResource shift the focus. Data gets modeled more strongly as a reactive dependency. An httpResource creates a resource for HTTP GET data and updates itself when the signal-based request description changes. It still uses Angular’s HttpClient and its infrastructure underneath, including interceptors.

That means: not every data flow needs an explicit initial load in the old style.

You can think of a resource as an asynchronous dependency whose value gets exposed through signals.

httpResource is the HTTP-adjacent variant of that. It’s meant for GET-based data flows and updates itself when its input changes via signals.

That fits well with flows like:

activeTournamentId changes
→ resource loads the matching tournament

Or:

Route param changes
→ schedule resource loads the current schedule

The parse option is interesting too. It lets you transform raw HTTP data, or validate it with a runtime schema library like Zod, before it gets delivered to the resource.

That fits well with our architecture idea:

DTO
→ parse / zod / ACL
→ ViewModel
→ Component

But this particular kind of data flow hasn’t fully landed in the agent’s responses everywhere yet.

Several bugs came from unclear state ownership.

For example:

Tournament got loaded, but not displayed.
Player selection turned up empty.
Changing a group created a new tournament.
Schedule didn't get re-derived.
An old schedule stayed valid even though groups had changed.

These weren’t just typos.

They were modeling mistakes.

The core question was always:

What's the source of truth?
What's derived?
When does something get persisted?
When does something get invalidated?

This mattered especially with the schedule.

The schedule isn’t independent domain state. It’s derived from the current group setup.

If groups change, the old schedule is invalid.

That has to be clearly represented in both the backend and the frontend:

Change groups
→ invalidate old schedule
→ status back to GROUPS_READY
→ open schedule tab
→ generate new schedule
→ status SCHEDULE_READY

When the agent failed to model this relationship clearly, it produced duplicate tournaments, stale schedules, or incorrect pairings.

The most important architectural point: take derivations seriously

Section titled “The most important architectural point: take derivations seriously”

The entire tournament setup flow consists of derivations.

active players
→ selectable players
selected players
→ group setup
group setup
→ schedule
schedule
→ SCHEDULE_READY

If an earlier state changes, later derivations have to become invalid.

That sounds trivial, but it was the hardest part.

Because a classic “set pattern” happily stores every intermediate state as a finished value, and then forgets to invalidate it on change.

The better mental model is:

Persisted state is the source. ViewModels and plans are derived. When the source changes, derived data has to be recreated or deliberately invalidated.

Despite all the corrections, the progress was impressive.

The app today has a complete planning flow:

Create tournament
Select players
Form groups
Account for reserves
Generate the schedule for round 1
Move the tournament status to SCHEDULE_READY

The tests got stronger too. Tests were especially important for:

Group sizes
Games per player
Match counts
Schedule invalidation
Tenant/club context
Backend validation

That’s exactly the point where AI-assisted development works: the agent produces structure, code, and tests quickly. But the human has to recognize and defend the domain invariants.

The hard parts were mostly things that can’t be solved locally, in one file.

For example:

Route concept
Active tournament context
Create vs. update
Schedule as a derivation
Invalidation
State across tabs
Theming across CDK/PrimeNG

These are cross-cutting concerns.

An agent can solve them, but it needs very clear prompts. As soon as the prompt is too open, it reaches for familiar patterns. And those patterns don’t always fit the architecture.

The step from CRUD to a real planning flow has been the most interesting part of the journey so far.

Tournament planning isn’t a big product. But it already contains plenty of real architecture questions:

How do you draw feature boundaries?
How do you prevent coupling?
How do you model wizard state?
When do you persist?
When do you invalidate derivations?
How do you keep UI layout agent-friendly?
How do you bring modern Angular reactivity into a codebase when a lot of examples still show old patterns?

The most important design takeaway:

For AI agents, a classic 12-column grid is often better than a freely described flex layout.

The most important architecture takeaway:

Agents can generate code quickly, but they need clear source-of-truth rules.

And the most important domain takeaway:

Planning round 1 is a setup problem. Round 2 is a results problem.

That’s why tournament planning deliberately stops at SCHEDULE_READY for now.

The next big use case isn’t planning anymore.

The next big use case is tournament day.