Week six of building MiseOS focused on something less flashy than AI suggestions or menu publishing, but just as important: wiring the application together.
Until now, most value lived in the domain and service layers. Business rules worked, entities guarded invariants, and services orchestrated workflows. But if HTTP requests cannot reliably reach those services, the architecture is incomplete.
This week was about turning the codebase into a real running server:
- Controllers translating HTTP into service calls
- Route modules exposing domain endpoints
- A DI container wiring dependencies
- Server configuration and application startup
- Exception handling with structured error responses
- Integration tests with REST-assured against real PostgreSQL
Avoiding Spaghetti Wiring#
One thing I wanted to avoid early was “spaghetti wiring” — where object creation is scattered everywhere and dependencies become hard to reason about.
A typical bad path looks like this:
Controller → new Service → new DAO → new HTTP Client
↘ another ServiceIt works at first, but over time it becomes fragile, hard to test, and painful to refactor because construction logic leaks into runtime logic.
In MiseOS, I avoid this by centralizing all object creation in a DIContainer and keeping setup concerns in dedicated config classes.
That way classes focus on behavior, not assembly.
The Eye-Opener: Query Params vs Path Params for Filtering#
Before this week, many reads were basically getAll().
When I started exposing filters in endpoints, I initially leaned toward path-style variants:
GET /ingredient-requests/delivery-date/2026-03-06
GET /ingredient-requests/status/PENDINGTechnically valid, but it felt wrong. Then I remembered the API style from a recent TMDB mini-project: filtering through query params.
That pushed me toward:
GET /ingredient-requests?deliveryDate=2026-03-06
GET /ingredient-requests?status=PENDING
GET /ingredient-requests?status=PENDING&deliveryDate=2026-03-06&stationId=2This was a major design improvement.
Rule I’m adopting going forward#
- Path params identify a concrete resource
/ingredient-requests/{id}
- Query params shape, filter, or sort a collection
/ingredient-requests?status=PENDING&stationId=2
Filtering is not a different resource. It is a different projection of the same collection.
This also meant refactoring several DAOs away from multiple hardcoded query methods like findByStatus, findByStatusAndDate, getAll — replacing them all with a single findByFilter that accepts nullable parameters. Much cleaner.
Controllers: HTTP Translation Layer (Not Business Logic)#
Controllers now do exactly four things:
- Read request data (
path,query, body, auth context) - Convert to typed inputs/DTOs
- Delegate to service
- Return JSON response
No business decisions in controller methods.
public void getAll(Context ctx)
{
Long userId = SecurityUtil.requireUserId(ctx);
Status status = RequestUtil.getQueryStatus(ctx, "status");
LocalDate deliveryDate = RequestUtil.getQueryDate(ctx, "deliveryDate");
RequestType requestType = RequestUtil.getQueryRequestType(ctx, "requestType");
Long stationId = RequestUtil.getQueryLong(ctx, "stationId");
List<IngredientRequestDTO> requests =
ingredientRequestService.getRequests(userId, status, deliveryDate, requestType, stationId);
ctx.status(200).json(requests);
}RequestUtil: Typed Parameter Parsing#
All query and path parameter parsing goes through a shared RequestUtil. The naming convention is intentional:
require*— throws if missing or invalid (mandatory params)get*— returns null if absent (optional filters)
// Mandatory — throws 400 if missing or invalid
Long id = RequestUtil.requirePathId(ctx, "id");
// Optional — returns null if not provided
Status status = RequestUtil.getQueryStatus(ctx, "status");
LocalDate date = RequestUtil.getQueryDate(ctx, "deliveryDate");No raw ctx.queryParam calls anywhere in controllers. Consistent 400 responses for malformed input across the entire API.
Exception Handling: Mapping Domain Exceptions to HTTP#
One of the most important decisions this week was establishing a consistent mapping between what goes wrong in the application and what the client receives. Every exception type is handled in one place — the ExceptionController — which keeps error behavior predictable and easy to change.
The full mapping:
| Exception | HTTP Status | Meaning |
|---|---|---|
IllegalArgumentException | 400 | Bad input — wrong format, out of range |
ValidationException | 400 | Business validation failure |
EntityNotFoundException | 404 | Resource does not exist |
UnauthorizedActionException | 403 | User does not have the required role |
ConflictException | 409 | Valid request but conflicts with current state |
DatabaseException | 500 | Persistence failure — hides internal detail from client |
AIIntegrationException | 502 | External AI service unavailable |
WeatherIntegrationException | 502 | External weather service unavailable |
TranslationException | 502 | External translation service unavailable |
A key design point: ConflictException was introduced specifically to distinguish state conflicts from bad input. “Cannot delete a published menu” or “cannot update an approved request” are not bad requests — the input is valid, but the current state of the resource makes the operation impossible. That is 409, not 400.
Every error response also carries a unique request reference ID, making it possible to match a client error to the exact server log entry:
{
"status": 409,
"message": "Cannot delete a finalized shopping list",
"path": "/api/v1/shopping-lists/3",
"referenceId": "a6aeef18"
}Routes: Modular API Surface per Domain#
Instead of one giant route file, endpoints are grouped by domain — UserRoute, StationRoute, DishRoute, WeeklyMenuRoute, IngredientRequestRoute, ShoppingListRoute. Each receives the exact controller it needs and exposes an EndpointGroup.
One rule that proved important: static routes must be declared before dynamic routes. In Javalin, if /{id} appears before /current, the string "current" gets matched as an id:
// Wrong — /{id} catches "current" as an id
get("/{id}", controller::getById);
get("/current", controller::getCurrent);
// Correct — static routes first
get("/current", controller::getCurrent);
get("/{id}", controller::getById);SSE: Streaming AI Suggestions#
One endpoint used a different pattern entirely — the MenuInspirationController streams AI dish suggestions using Server-Sent Events instead of a single response:
public void getStreamingSuggestions(SseClient client)
{
client.keepAlive();
Long userId = SecurityUtil.requireUserId(client.ctx());
menuInspirationService.streamDailyInspiration(
userId,
status -> client.sendEvent("status", status),
dish -> client.sendEvent("dish", dish),
() -> { client.sendEvent("done", "complete"); client.close(); },
error -> { client.sendEvent("error", error.getMessage()); client.close(); }
);
}The client receives status messages first (“Analyserer vejrdata…”), then individual dish objects as they arrive, then a done event. No polling, no waiting for the full response — the UI feels alive while the AI is thinking.
How the Application Starts Up#
Starting the application involves a chain of responsibilities. Rather than putting everything in one class, each concern has its own home:
- HibernateConfig sets up the database connection and registers all entities
- ApiConfig loads external API keys and URLs (Gemini, DeepL, OpenMeteo)
- ObjectMapperConfig controls how Java objects are converted to and from JSON
- DIContainer creates all DAOs, services, controllers, and route modules in the right order
- ServerConfig creates the Javalin server, attaches middleware, routes, and exception handlers
- ApplicationConfig ties everything together with two simple methods:
startServer(port, emf)andstopServer(app)
The diagram below shows the full picture — what gets created first, and what depends on what.
Figure: The full dependency graph of the application. Objects are created from the bottom up — database and external clients first, then DAOs, then services, then controllers, then routes attached to the running server.
How a Request Travels Through the System#
Once the server is running, every incoming HTTP request follows the same path through the layers. The diagram below traces that journey from the moment a client sends something to the moment a response comes back.
The request first hits ServerConfig, where a unique ID is assigned and all incoming details are logged. It is then routed to the appropriate Controller, which reads path params, query params, and the request body and turns them into typed inputs. The Service layer applies the business logic — it may read from or write to the database, or reach out to an external API such as Gemini for AI, OpenMeteo for weather, or DeepL for translation. The result travels back as a structured JSON response, with the request ID appearing in both the response and the server log so the full lifecycle is traceable.
What Was Hard#
Optional filter parameters behaved differently depending on which database was running. A common shorthand for optional JPQL filters looked clean on paper:
WHERE (:status IS NULL OR entity.status = :status)
AND (:date IS NULL OR entity.date = :date)It worked fine during development but failed with a cryptic error when the tests ran against the real database. The database could not determine the type of a null date parameter when it had no surrounding context to infer from.
The fix was to build the query dynamically — only adding a filter clause when a value is actually provided, and only binding the parameter when the clause exists in the query. More verbose, but it works reliably everywhere:
if (deliveryDate != null) jpql.append("AND entity.date = :date ");
// ...
if (deliveryDate != null) query.setParameter("date", deliveryDate);The lesson: test against the same database you run in production. A pattern that works in the development environment can silently fail in the real one.
What I Learned This Week#
- Wiring is architecture work — it shapes how the whole system evolves, not just how it starts.
- Query params for filtering made the API more natural and the DAO interface cleaner.
- Strict layer responsibilities meant less refactoring pain when requirements changed.
- 400 and 409 are not interchangeable. Bad input is different from state conflict.
- Test against the same database you deploy to. Development shortcuts hide real problems.
Next Step#
With routing and controller wiring stable, next focus is expanding API integration tests with REST assured and maybe adding some Websocket endpoints for real-time updates in the UI. The goal is to have a fully tested API layer that can evolve confidently as new features are added.
MiseOS now feels less like “a set of classes” and more like an actual backend system.
This is part 6 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time.
