Skip to content

The first vertical slice

After the previous infrastructure steps, the tournament app is technically ready to go.

The application runs. The deployment is in place. CI checks static quality gates. Keycloak is integrated. The API gateway validates JWTs. Transloco, Tailwind, and PrimeNG form the foundation for UI and text.

That’s the point where the project is finally allowed to get domain-specific.

And that’s exactly where the next trap lurks.

Because the first domain step often decides how a project develops from here on. If you just say “build player management” now, code appears quickly. Maybe even working code. But not necessarily a structure that holds up.

That’s why this step isn’t just about CRUD.

It’s about the first vertical slice.

The domain idea behind the app is manageable: a club wants to prepare and run table tennis tournaments. That takes players, tournaments, groups, schedules, results, and history.

You could just start with any visible feature.

For example:

Create a player
Create a tournament
Assign groups via drag and drop
Generate a schedule

The visible appeal probably lies with group assignment. Drag and drop, groups, ranking, tournament day — that feels like a product.

Still, that’s not the best first step.

Player management is smaller. More boring. Less impressive.

But that’s exactly why it’s a good first domain vertical slice.

It’s simple enough, domain-wise, not to immediately sink into tournament rules. At the same time, it touches every relevant layer:

Frontend
API gateway
Backend service
Database
Auth context
Tenant/club context
UI
i18n
Validation

That makes it ideal for fully proving out the architecture once.

The first slice isn’t just supposed to prove that a form can be saved.

It’s supposed to show whether the chosen structure holds up.

For player management, that means:

User opens the players page.
User sees players within the current club context.
User creates a player.
The player gets assigned to the club, server-side.
The player gets audit information.
The API is protected.
The database stores the record.
The UI updates.

That’s not a big feature. But it’s a complete path through the system.

That’s exactly why it’s a good first domain step.

An important decision concerns the frontend structure.

The obvious technical arrangement would be:

libs/tournament/model
libs/tournament/domain
libs/tournament/presentation

At first glance, that looks clean. Models sit with models. Domain code sits with domain code. Presentation code sits with presentation code.

The problem: this structure is horizontal.

As the domain grows, everything would quickly end up there:

libs/tournament/model
├─ player.model.ts
├─ tournament.model.ts
├─ schedule.model.ts
├─ match.model.ts
└─ standings.model.ts
libs/tournament/domain
├─ players.store.ts
├─ tournaments.store.ts
├─ schedule.store.ts
└─ standings.store.ts
libs/tournament/presentation
├─ players-page.component.ts
├─ tournament-setup-page.component.ts
├─ schedule-page.component.ts
└─ standings-page.component.ts

Formally, that would be sorted. Domain-wise, though, it would already be tangled together.

That’s why the domain gets organized vertically instead.

For player management, a dedicated structure emerges:

libs/tournament/players/
├─ model/
├─ domain/
└─ presentation/

Later domain areas get their own areas:

libs/tournament/schedule/
├─ model/
├─ domain/
└─ presentation/
libs/tournament/tournaments/
├─ model/
├─ domain/
└─ presentation/

The important point is:

players stays with players. schedule stays with schedule. tournament stays with tournament.

That sounds trivial. It isn’t, though.

In frontend projects especially, the Big Ball of Mud often doesn’t come from nobody wanting structure. It comes from the structure being thought about too technically.

The decision for dedicated feature libs is deliberate.

It’s meant to deliver several things:

  • clear boundaries
  • better focus while developing
  • smaller review surfaces
  • better guidance for coding agents
  • less accidental coupling
  • easier later extraction
  • less pressure to move everything into shared

That means a later task can be clearly bounded:

Work only in libs/tournament/players/**.
No changes to schedule.
No changes to tournaments.

That’s helpful for human developers.

For coding agents, it’s almost even more important.

An agent orients strongly on existing patterns. If the first domain area preserves the vertical slice cleanly, it’s more likely that later domain areas will emerge the same way.

If the first domain area is instead scattered across global folders, that scattering becomes the new pattern.

Another rule gets set right along with this first slice:

Domain features don’t communicate in the frontend through each other’s stores.

Concretely:

Player management is allowed to load, display, create, and delete players.

But it doesn’t become the global data source for schedule, tournament setup, or other later features.

If schedule needs players later, schedule doesn’t just reach into the PlayersStore.

Instead, schedule fetches its own data through its own backend endpoint.

Why?

Because different domain areas often need different views of the same data.

Player management might need:

First name
Last name
Initial ranking
Date of birth
Status

Tournament setup might need:

Player ID
Display name
Ranking
Availability
Already assigned

Schedule might need:

Player ID
Display name
Ranking snapshot for this tournament

These aren’t necessarily the same models.

If you build a global PlayerModel too early, everything ends up hanging off everything else.

That’s why this project follows this rule:

Reuse isn’t automatically better than a clear boundary.

Controlled duplication is allowed.

The API gateway gets structured around domain areas too.

Not like this:

controllers/
services/
dto/

But like this:

apps/apis/tournament-api/src/app/
├─ players/
│ ├─ dto/
│ ├─ players.controller.ts
│ ├─ players.service.ts
│ └─ players.module.ts
├─ tournaments/
├─ schedule/
└─ auth/

The players.service.ts in the API gateway isn’t a domain service here.

It’s a gateway service.

Its job is:

  • accept HTTP requests
  • validate DTOs
  • evaluate the authenticated user
  • determine or pass along the current club/tenant context
  • forward commands and queries to the backend service
  • map responses for the frontend

The real domain logic doesn’t live in the API gateway.

But the API structure still keeps domain ownership visible.

A player endpoint lives in the players area. A later schedule endpoint lives in the schedule area. A tournament endpoint lives in the tournament area.

That doesn’t prevent every kind of coupling. But it makes coupling more visible.

Vertical organization in the backend service

Section titled “Vertical organization in the backend service”

The backend service stays a single service.

So there’s no dedicated players-service, no dedicated schedule-service, and no dedicated tournaments-service.

For the MVP, that would be unnecessary.

Still, the service gets organized vertically internally too:

apps/services/tournament-service/src/app/
├─ players/
│ ├─ dto/
│ ├─ player.entity.ts
│ ├─ players.repository.ts
│ ├─ players.service.ts
│ └─ players.module.ts
├─ tournaments/
├─ schedule/
└─ shared/

That’s the core of the architecture decision made so far:

A deployment monolith, but not a domain mishmash.

The domain modules end up sharing one database. But they shouldn’t arbitrarily mix their controllers, DTOs, repositories, and use cases.

The shared database is an operational decision.

It’s not a free pass for one global repository folder.

An important point in this architectural approach is the deliberate handling of duplication.

A lot of teams try to extract shared models, DTOs, or services very early.

That looks tidy.

But it’s often dangerous.

Because at the start, it isn’t clear yet which similarities are stable and which just happen to look alike.

A PlayerVm in player management isn’t automatically the same thing as a PlayerSelectionItem in tournament setup.

A PlayerDto for CRUD isn’t automatically the same thing as a ScheduledPlayerSnapshot for a schedule.

So the rule is:

Better a small, controlled duplication than the wrong reuse.

Shared models only move into shared once the reuse is domain-stable.

Not because two properties happen to share a name.

Even with this first domain feature, the question of data scope comes up.

Right now, this is about one internal club tournament. But the modeling shouldn’t be so narrow that later extensions become unnecessarily hard.

What happens if multiple clubs use the app later?

What happens if an admin creates a club and invites trainers to it?

What happens if tournaments later span multiple clubs?

None of that is meant to be built now.

But it also shouldn’t be built against.

The clean domain model is:

The club is the tenant.

Technically, you could talk about tenantId. Domain-wise, clubId is probably the better term.

A possible future model could look like this:

Club
├─ Admins
├─ Trainers
├─ Players
└─ Tournaments

With memberships:

ClubMembership
├─ clubId
├─ userId
├─ role
└─ status

None of that gets implemented for the MVP.

But the player data should already be tied to a club context.

The Keycloak integration already provides a stable technical user ID:

userId = token.sub

This user ID comes from the access token and gets validated by the API gateway.

For data modeling, that means:

createdByUserId
updatedByUserId

These fields are audit information.

They answer:

Who created or last changed this record?

The club data scope is separate from that.

It’s determined by the club:

clubId

That gives an important rule:

sub identifies the user. clubId identifies the club data scope. createdByUserId is audit, not automatically the tenant boundary.

For the first vertical slice, clubId can still come from a configuration value, for example a default club ID.

The frontend doesn’t set this ID itself.

The server determines the context.

An obvious simple safeguard would be:

where: {
clubId: currentClubId,
createdByUserId: currentUser.userId
}

That’s very restrictive.

But for a club app, it’s probably wrong for the club model.

If an admin creates players, trainers should presumably still be able to see those players later and use them for tournaments. The players don’t belong to the individual user. They belong to the club.

So the better direction is:

where: {
clubId: currentClubId;
}

Whether a user is even allowed to use this clubId context gets checked later, through memberships and roles.

For the MVP, this membership logic can still be missing. But the modeling should already point in the right direction.

The first domain vertical slice: players CRUD

Section titled “The first domain vertical slice: players CRUD”

The first domain slice is player management.

Deliberately small:

List
Create
Delete or deactivate

No detail page.

No complex editing.

No tournament assignment.

No schedule.

No drag and drop.

The UI can initially consist of one overview page:

  • a table of players
  • a “create player” button
  • a modal/dialog for create
  • a per-row action to delete or deactivate

What matters isn’t that this feature is spectacular.

What matters is that it cleanly proves the first domain vertical slice.

With players, you should be careful about actual deletion.

For an MVP, a delete button in the UI is understandable. But behind it, domain-wise, there can be a soft delete or a deactivation.

So:

UI: delete
Backend: deactivate

That has advantages:

  • accidentally deleted players don’t disappear from history immediately
  • later tournaments can still reference old players
  • audit stays possible
  • the model is more robust for history

For the first vertical slice, a simple decision is enough:

No hard delete, if history might become relevant later.

Here too: don’t overbuild, but don’t build yourself into a corner either.

The first domain slice stays narrow.

Not included:

Tournament list
Tournament setup
Group assignment
Drag and drop
Schedule generator
Result recording
Parent live view
Club administration
Invitations
Role matrix
Membership management

That might seem harsh. But this exact boundary protects the project.

A large domain prompt would very quickly touch several boundaries at once again. Then it would be hard to tell whether a problem comes from the UI, the API, the service, the database, auth, or the modeling.

The first slice should stay small so it stays reviewable.

What this step prepares for later features

Section titled “What this step prepares for later features”

If the players slice sits cleanly, a lot has been gained.

There’s now a pattern for:

  • vertical frontend libs
  • API gateway domain folders
  • service domain modules
  • protected endpoints
  • auth context in the request
  • clubId as the club data scope
  • audit via createdByUserId
  • i18n in the UI
  • PrimeNG components
  • Tailwind layout
  • small domain prompts

Later features can orient themselves on this.

For example, tournament setup:

libs/tournament/tournaments/model
libs/tournament/tournaments/domain
libs/tournament/tournaments/presentation

Or schedule:

libs/tournament/schedule/model
libs/tournament/schedule/domain
libs/tournament/schedule/presentation

But they don’t just reach into players.

They get their own backend queries and their own ViewModels.

That’s the protection against the Big Ball of Mud.

The first domain vertical slice is deliberately unspectacular.

Show players. Create players. Delete or deactivate players.

But architecturally, this step matters.

It decides whether the application grows vertically by domain, or whether it immediately frays back into technical catch-all folders.

The central decision is:

Don’t collect everything globally first and hope you can sort it out later. Organize around domain boundaries from the start.

For this project, that means:

Frontend organized vertically by domain
API gateway organized vertically by domain
Backend service organized vertically by domain
shared database
deliberate duplication
small shared
clubId as the club data scope
sub as the user identity

This isn’t a big enterprise blueprint.

It’s a small, pragmatic structural decision so the MVP doesn’t turn into a Big Ball of Mud.

And that’s exactly why the domain work starts with players.