Disclaimer: This blog post is automatically generated from project documentation and technical proposals using AI assistance. The content represents our development journey and architectural decisions. Code examples are simplified illustrations and may not reflect the exact production implementation.
Table of Contents
- Introduction
- The Challenge
- Why a Collection Endpoint Matters
- Designing the API
- Breaking Down the UI Dependencies
- The Refactoring Journey
- Achievements and ROI
- What We Learned
- Future Considerations
Introduction
In software development, the most impactful changes often aren’t about adding new features—they’re about rethinking how components interact. This iteration was one of those transformative moments where we fundamentally changed how our web UI communicates with our padel scoring system.
Caroline, Claude, and I embarked on a journey to solve what seemed like a simple problem: “How do we display a list of recent matches?” But as we dug deeper, we uncovered an opportunity to significantly improve our architecture, making it more maintainable, scalable, and aligned with REST principles.
The Challenge
Our scoring system had an interesting architectural split personality. On one hand, we had a beautifully crafted event-sourced domain with CQRS pattern separating writes and reads. On the other hand, our web UI was directly querying the database, tightly coupling it to our data layer.
The symptoms were clear:
- No way to browse matches without knowing their exact IDs
- UI had database credentials and direct PostgreSQL/ClickHouse connections
- Mixed responsibilities - the UI was doing both presentation and data access
- Limited reusability - other clients couldn’t easily access match data
The existing GetLatestMatches query was a band-aid solution designed for a specific widget use case. It returned a simple array with hardcoded limits (1-50 items) and no pagination metadata. While it worked for its narrow purpose, it wasn’t suitable for a proper REST collection endpoint.
Why a Collection Endpoint Matters
REST APIs are about resources and how clients discover and interact with them. Without a collection endpoint, we had an incomplete REST API—like having a library where you can only get books if you already know their catalog numbers.
The REST Principle
A proper collection endpoint enables:
- Discoverability - Clients can explore available resources
- Pagination - Handle datasets that grow over time
- Sorting - Let clients organize data meaningfully
- HATEOAS - Hypermedia links guide navigation
Caroline pointed out during our design discussions that this wasn’t just about adding an endpoint—it was about embracing REST principles properly. The collection endpoint would be the entry point for discovering matches.
Designing the API
We designed GET /matches with these capabilities:
GET /matches?page=1&limit=10&sort=createdAt&direction=desc
Query Parameters
- page (default: 1) - 1-indexed for human-friendly URLs
- limit (default: 10, max: 100) - Configurable page size
- sort (default: createdAt) - Sort by: createdAt, updatedAt, startedAt, finishedAt
- direction (default: desc) - Sort direction: asc or desc
Validation Strategy
We took a strict approach to validation—if parameters are invalid, return 400 Bad Request immediately:
if (limit > 100) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Limit cannot exceed 100',
});
}
const validSortFields = [
'created_at',
'updated_at',
'started_at',
'finished_at',
];
if (sort && !validSortFields.includes(sort)) {
return reply.status(400).send({
error: 'Bad Request',
message: `Invalid sort field. Must be one of: ${validSortFields.join(
', '
)}`,
});
}
This “fail fast” approach prevents invalid requests from reaching the database and provides clear feedback to clients.
Response Structure with HATEOAS
The response includes both data and navigation:
{
"matches": [
{
"id": "uuid",
"score": "6-4 3-6 6-2",
"createdAt": "2025-12-09T10:30:00Z",
"links": [
{ "href": "/matches/uuid", "rel": "self", "method": "GET" },
{ "href": "/matches/uuid/score", "rel": "score", "method": "GET" },
{ "href": "/projections/match/uuid", "rel": "projections", "method": "GET" }
]
}
],
"pagination": {
"total": 10,
"current": 1,
"count": 47,
"limit": 10
},
"links": [
{ "href": "/matches?page=1&limit=10", "rel": "self", "method": "GET" },
{ "href": "/matches?page=2&limit=10", "rel": "next", "method": "GET" }
]
}
Each match includes HATEOAS links, enabling clients to navigate without constructing URLs manually.
The scoreLine Enhancement
One key improvement was adding a scoreLine field to all score responses. Instead of clients parsing the score structure and formatting it themselves, the API now returns a human-readable string like "6-4 3-6 6-2".
This seemingly small change has big implications:
- Consistency - All clients display scores identically
- Simplicity - No client-side score formatting logic needed
- DRY Principle - Formatting logic lives in one place
We implemented this in the domain layer using the MatchScore aggregate:
class MatchScore {
getScoreLine(): string {
return this.state.sets
.filter((set, index) => {
if (index === 0) return true; // Always include first set
return set.games[0] !== 0 || set.games[1] !== 0; // Exclude unplayed sets
})
.map((set) => `${set.games[0]}-${set.games[1]}`)
.join(' ');
}
}
Database Performance
To ensure fast queries, we added indexes on all sortable fields:
CREATE INDEX IF NOT EXISTS idx_matches_created_at ON matches(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_matches_updated_at ON matches(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_matches_started_at ON matches(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_matches_finished_at ON matches(finished_at DESC);
PostgreSQL can use these indexes for efficient sorting and pagination, regardless of sort direction.
Breaking Down the UI Dependencies
With the API in place, we faced the bigger challenge: refactoring the UI to be an API client rather than a database client.
The Old Architecture
graph LR
UI[Web UI] -->|Direct Query| PG[(PostgreSQL)]
UI -->|Direct Query| CH[(ClickHouse)]
UI -->|CQRS Commands| APP[Application]
style UI fill:#e8f5e9
style PG fill:#e0f2f1
style CH fill:#fff9c4
style APP fill:#e3f2fd
The UI service had:
- PostgreSQL connection credentials
- ClickHouse connection credentials
- Full Application instance with CQRS handlers
- Domain knowledge and business logic
The New Architecture
graph LR
UI[Web UI] -->|HTTP GET| API[REST API]
UI -->|HTTP PUT| API
API -->|Query| PG[(PostgreSQL)]
API -->|Query| CH[(ClickHouse)]
style UI fill:#e8f5e9
style API fill:#fff3e0
style PG fill:#e0f2f1
style CH fill:#fff9c4
After refactoring:
- UI has zero database dependencies
- UI makes HTTP calls to API service
- API handles all data access
- Clean separation of concerns
The Refactoring Journey
Step 1: Replace Query Operations
We systematically replaced each database query with an API call:
Before:
const latestMatches = await request.application.executeQuery({
type: QueryTypes.GetLatestMatches,
params: { latest: 5 },
});
After:
const response = await fetch(
`${apiUri}/matches?page=1&limit=5&sort=created_at&direction=desc`
);
const data = await response.json();
const matches = data.matches || [];
Step 2: Replace Command Operations
Even match creation went through the API:
Before:
await request.application.executeCommand({
type: CommandTypes.CreateMatch,
details: { id: matchId },
});
After:
await fetch(`${apiUri}/matches/${matchId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
Step 3: Remove Application Dependency
The most satisfying change was removing the entire Application layer from the UI:
Before:
import Application from '@internal/matches-app';
const app = await Application.Create();
fastify.addHook('preHandler', async (request) => {
request.application = app;
request.apiUri = INTERNAL_API_URI;
});
After:
// No Application import needed!
fastify.addHook('preHandler', async (request) => {
request.apiUri = INTERNAL_API_URI;
});
Step 4: Update Docker Configuration
We removed database secrets from the UI service:
Before:
ui:
secrets:
- oidc_config
- postgres_connection
- clickhouse_connection
- session_keys
After:
ui:
secrets:
- oidc_config
- session_keys
depends_on:
api:
condition: service_started
The UI now depends on the API service, not the databases.
Step 5: Clean Up Domain Layer
With the UI using the collection endpoint, we removed the obsolete GetLatestMatches query entirely:
- Removed from
QueryTypesenum - Deleted
GetLatestMatchesParamstype - Deleted
GetLatestMatchesQuerytype - Deleted
GetLatestMatchesQueryHandlerclass - Removed from application registration
This cleanup eliminated technical debt and reduced maintenance burden.
Achievements and ROI
Quantifiable Improvements
Before Refactoring:
- UI service: 384MB memory, full Application instance
- Database connections: PostgreSQL + ClickHouse from UI
- Code coupling: UI imports from 3 internal packages
- API coverage: 2 endpoints (GET /:id, PUT /:id)
After Refactoring:
- UI service: Lightweight HTTP client only
- Database connections: Zero from UI
- Code coupling: UI imports nothing internal
- API coverage: 5 endpoints (added collection, sorting, score)
Architectural Benefits
- Separation of Concerns - UI is now purely presentation layer
- Security Posture - Reduced attack surface (no DB credentials in UI)
- Independent Scaling - UI can scale without database load
- Reusability - Other clients can use same API endpoints
- Maintainability - Changes to data layer don’t affect UI
Development Velocity
The refactoring enables:
- Faster UI development - No need to understand CQRS internals
- Easier testing - Mock API responses instead of database state
- Better collaboration - Frontend and backend teams work independently
- API-first mindset - Future features designed as APIs first
What We Learned
Care and Continuous Improvement
This refactoring embodied our value of continuous improvement. The old code worked, but we cared enough to make it better. We didn’t settle for “good enough”—we pushed for architectural excellence.
Challenge and Creativity
Caroline challenged us to think beyond just adding an endpoint. “Why stop at a simple list?” she asked. This led to the comprehensive sorting, pagination, and HATEOAS implementation that makes the API truly RESTful.
Collaboration
The collaboration between Claude’s documentation, Caroline’s insights, and my implementation created something better than any of us could have done alone. Each iteration refined the design based on collective feedback.
Future Considerations
Filtering Capabilities
The next logical step is adding filtering:
GET /matches?status=finished&dateFrom=2025-12-01&dateTo=2025-12-31
This would enable:
- Viewing only completed matches
- Date range queries for historical analysis
- Status filtering for monitoring active matches
Cursor-Based Pagination
For very large datasets, offset-based pagination can become inefficient. Cursor-based pagination would provide:
- Consistent results during concurrent modifications
- Better performance for deep pagination
- Stateless server design
Field Selection
Allow clients to request specific fields:
GET /matches?fields=id,createdAt,score
This would reduce payload size and improve performance for clients that only need certain fields.
API Versioning Strategy
As the API evolves, we’ll need a versioning strategy. Options include:
- URL versioning:
/v1/matches,/v2/matches - Header versioning:
Accept: application/vnd.api+json; version=1 - Content negotiation: Different mime types
Conclusion
What started as “let’s add a collection endpoint” turned into a comprehensive refactoring that improved our architecture fundamentally. We didn’t just add features—we made the system better.
The UI is now a lightweight, maintainable service that does one thing well: presentation. The API is a proper REST interface that other clients can use. And most importantly, we can move forward with confidence knowing our architecture is sound.
This is what continuous improvement looks like in practice—not grand rewrites, but thoughtful iterations that compound into significant improvements over time.
Built with care, challenged with creativity, and achieved through collaboration.
Next Steps:
- Implement filtering capabilities for the collection endpoint
- Add API documentation with OpenAPI/Swagger
- Consider cursor-based pagination for performance
- Explore caching strategies for frequently accessed collections