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.
Why not just start anywhere?
Section titled “Why not just start anywhere?”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 playerCreate a tournamentAssign groups via drag and dropGenerate a scheduleThe 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:
FrontendAPI gatewayBackend serviceDatabaseAuth contextTenant/club contextUIi18nValidationThat makes it ideal for fully proving out the architecture once.
What the first slice is supposed to show
Section titled “What the first slice is supposed to show”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.
Vertical organization in the frontend
Section titled “Vertical organization in the frontend”An important decision concerns the frontend structure.
The obvious technical arrangement would be:
libs/tournament/modellibs/tournament/domainlibs/tournament/presentationAt 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.tslibs/tournament/domain├─ players.store.ts├─ tournaments.store.ts├─ schedule.store.ts└─ standings.store.tslibs/tournament/presentation├─ players-page.component.ts├─ tournament-setup-page.component.ts├─ schedule-page.component.ts└─ standings-page.component.tsFormally, 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:
playersstays withplayers.schedulestays withschedule.tournamentstays withtournament.
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.
Why feature libs?
Section titled “Why feature libs?”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.
No frontend data handoff between features
Section titled “No frontend data handoff between features”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 nameLast nameInitial rankingDate of birthStatusTournament setup might need:
Player IDDisplay nameRankingAvailabilityAlready assignedSchedule might need:
Player IDDisplay nameRanking snapshot for this tournamentThese 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.
Vertical organization in the API gateway
Section titled “Vertical organization in the API gateway”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.
Duplication is allowed
Section titled “Duplication is allowed”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.
Tenant or club?
Section titled “Tenant or club?”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└─ TournamentsWith memberships:
ClubMembership├─ clubId├─ userId├─ role└─ statusNone of that gets implemented for the MVP.
But the player data should already be tied to a club context.
sub, clubId, and audit
Section titled “sub, clubId, and audit”The Keycloak integration already provides a stable technical user ID:
userId = token.subThis user ID comes from the access token and gets validated by the API gateway.
For data modeling, that means:
createdByUserIdupdatedByUserIdThese 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:
clubIdThat gives an important rule:
subidentifies the user.clubIdidentifies the club data scope.createdByUserIdis 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.
Why not filter by createdByUserId?
Section titled “Why not filter by createdByUserId?”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:
ListCreateDelete or deactivateNo 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.
Delete or deactivate?
Section titled “Delete or deactivate?”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: deleteBackend: deactivateThat 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.
What’s deliberately not being built
Section titled “What’s deliberately not being built”The first domain slice stays narrow.
Not included:
Tournament listTournament setupGroup assignmentDrag and dropSchedule generatorResult recordingParent live viewClub administrationInvitationsRole matrixMembership managementThat 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
clubIdas 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/modellibs/tournament/tournaments/domainlibs/tournament/tournaments/presentationOr schedule:
libs/tournament/schedule/modellibs/tournament/schedule/domainlibs/tournament/schedule/presentationBut 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.
Conclusion
Section titled “Conclusion”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 domainAPI gateway organized vertically by domainBackend service organized vertically by domainshared databasedeliberate duplicationsmall sharedclubId as the club data scopesub as the user identityThis 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.