Week five of building MiseOS. The service layer is where everything connects — HTTP requests come in from controllers, business logic runs, and data goes out through the DAOs. Getting this layer right determines how readable and maintainable the rest of the codebase will be.
What I Built This Week#
Ten service classes covering the full domain and external APIs:
| Service | Responsibility |
|---|---|
AllergenService | CRUD + EU allergen seeder (14 official allergens, bilingual DA/EN, double-seed protection) |
DishService | CRUD + activate/deactivate + available dishes grouped by station |
DishSuggestionService | Submit, approve, reject, update — full suggestion lifecycle |
AiService | Normalize data, generate AI-powered dish suggestions based on station weather and ISO certification |
DishTranslationService | Translate dish names and descriptions to English or a target language |
WeeklyMenuService | Create menu, add/remove/update slots, translate, publish |
StationService | CRUD + name uniqueness |
UserService | Register, login, update profile, change role/email/password, delete |
IngredientRequestService | Create, approve, reject, filter by status and delivery date |
ShoppingListService | Create, approve shopping list, item management, mark ordered, finalize |
Each service depends only on interfaces — never on concrete classes. Each one validates, authorizes, fetches, executes, and returns in the same order every time.
The Service Layer is the Heart of the Application#
To understand the flow and responsibilities of the service layer, I diagrammed two of the program’s core features: dish suggestion and menu publication.
These diagrams were invaluable for catching edge cases and ensuring a smooth user experience. They helped reveal the full lifecycle — how a line cook’s suggestion travels through the system, and how a head chef builds and publishes a weekly menu. From this, it became clear where validation should happen, where state transitions belong, and how the system should respond at each step.
The following diagram guided the implementation of DishSuggestionService:
Dish Suggestion Lifecycle#
sequenceDiagram
actor LineCook
actor HeadChef
participant DishSuggestionService
participant DishSuggestionDAO
participant DishSuggestionEntity as DishSuggestion (Entity)
participant DishDAO
%% ── SUBMIT SUGGESTION ─────────────────────────────
LineCook->>DishSuggestionService: submitSuggestion(creatorId, dto)
DishSuggestionService->>DishSuggestionService: validateCreateInput(dto)
Note right of DishSuggestionService: validateName
validateDescription
validateRange(week/year)
DishSuggestionService->>DishSuggestionService: ensureIsKitchenStaff(user)
Note right of DishSuggestionService: HEAD_CHEF
SOUS_CHEF
LINE_COOK allowed
DishSuggestionService->>DishSuggestionEntity: checkCreationAllowed(LocalDate.now())
Note right of DishSuggestionEntity: Entity checks
submission deadline
DishSuggestionService->>DishSuggestionDAO: create(suggestion)
DishSuggestionDAO-->>DishSuggestionService: saved (PENDING)
DishSuggestionService-->>LineCook: DishSuggestionDTO
%% ── APPROVE SUGGESTION ────────────────────────────
HeadChef->>DishSuggestionService: approveDish(id, approverId)
DishSuggestionService->>DishSuggestionEntity: approve(approver)
Note right of DishSuggestionEntity: validate PENDING
set approver + timestamp
status → APPROVED
DishSuggestionService->>DishSuggestionDAO: update(suggestion)
DishSuggestionDAO-->>DishSuggestionService: updated
DishSuggestionService->>DishSuggestionService: map suggestion → Dish
Note right of DishSuggestionService: nameDA
descriptionDA
station + allergens
DishSuggestionService->>DishDAO: create(dish)
Note right of DishDAO: suggestion kept
for audit history
DishDAO-->>DishSuggestionService: dish saved
DishSuggestionService-->>HeadChef: DishSuggestionDTO (APPROVED)
Once dishes exist in the dish bank, the menu publication flow takes over. The head chef creates a weekly menu, adds dishes to slots, and publishes it for guests to see.
Menu Publication Lifecycle#
sequenceDiagram
actor HeadChef
actor Guest
participant DishService
participant WeeklyMenuService
participant MenuDAO
%% ── TRANSLATE DISH ────────────────────────────────
HeadChef->>DishService: translateDish(editorId, dishId)
Note right of DishService: DeepL translation
DA → EN
DishService-->>HeadChef: DishDTO (translated)
%% ── CREATE MENU ───────────────────────────────────
HeadChef->>WeeklyMenuService: createMenu(creatorId, dto)
WeeklyMenuService->>WeeklyMenuService: checkIfMenuExists()
Note right of WeeklyMenuService: Only one menu per week
WeeklyMenuService->>MenuDAO: create(menu)
MenuDAO-->>WeeklyMenuService: saved
WeeklyMenuService-->>HeadChef: WeeklyMenuDTO (DRAFT)
%% ── ADD MENU SLOT ─────────────────────────────────
HeadChef->>WeeklyMenuService: addMenuSlot(menuId, dto)
WeeklyMenuService->>WeeklyMenuService: validateDishForStation()
Note right of WeeklyMenuService: dish.station must
match slot station
WeeklyMenuService->>WeeklyMenuService: dish.isActive()
Note right of WeeklyMenuService: entity guards state
WeeklyMenuService->>MenuDAO: update(menu)
MenuDAO-->>WeeklyMenuService: slot added
WeeklyMenuService-->>HeadChef: WeeklyMenuDTO
%% ── PUBLISH MENU ──────────────────────────────────
HeadChef->>WeeklyMenuService: publishMenu(menuId)
WeeklyMenuService->>WeeklyMenuService: requireNotEmpty()
WeeklyMenuService->>WeeklyMenuService: validateAllDishesTranslated()
WeeklyMenuService->>WeeklyMenuService: menu.publish()
Note right of WeeklyMenuService: entity sets PUBLISHED
publisher + timestamp
WeeklyMenuService->>MenuDAO: update(menu)
MenuDAO-->>WeeklyMenuService: published
WeeklyMenuService-->>HeadChef: WeeklyMenuDTO (PUBLISHED)
%% ── GUEST READS MENU ──────────────────────────────
Guest->>WeeklyMenuService: getCurrentWeekMenu()
WeeklyMenuService->>MenuDAO: findByWeekAndYear()
MenuDAO-->>WeeklyMenuService: WeeklyMenu
WeeklyMenuService-->>Guest: WeeklyMenuDTO (DA + EN)
Every service method follows the same pattern: validate → authorize → fetch → execute → return DTOs.
Together, these services orchestrate the full lifecycle of dishes, menus, shopping lists, and ingredient requests across the kitchen system — making the service layer the true coordination point of the application.
From Suggestion to Dish Bank#
One of the most important flows this week is what happens when a head chef approves a dish suggestion. It is not just a status change — it creates a real Dish entity from the suggestion data.
@Override
public DishSuggestionDTO approveDish(Long dishId, Long approverId)
{
ValidationUtil.validateId(dishId);
ValidationUtil.validateId(approverId);
DishSuggestion suggestion = dishSuggestionDAO.getByID(dishId);
User approver = userReader.getByID(approverId);
suggestion.approve(approver);
DishSuggestion updated = dishSuggestionDAO.update(suggestion);
Dish dish = toDish(suggestion);
Dish createdDish = dishDAO.create(dish);
return DishSuggestionMapper.toDTO(updated);
}The suggestion stays in the database with APPROVED status — for history and audit. A new Dish is created carrying the same nameDA, descriptionDA, station, allergens, createdBy, targetWeek and targetYear. That dish now lives in the dish bank and can be placed into a weekly menu slot.
No Layer Trusts Another#
The most important structural decision this week was defensive programming across all three layers.
Each layer validates what it owns and nothing else:
Controller → Is the HTTP request well-formed? (id > 0, body present, user authenticated)
Service → Are the business rules satisfied? (unique name, authorized, valid range)
Entity → Is this object in a valid state? (not null, ensureDraft, invariants)ShoppingList.ensureDraft() is the clearest example. The entity refuses to add items, remove items, or finalize itself unless it is in DRAFT state — regardless of what the service already checked:
// ShoppingList.java
private void ensureDraft(String action)
{
if (this.shoppingListStatus != ShoppingListStatus.DRAFT)
{
throw new IllegalStateException("Cannot " + action + " - list is " + shoppingListStatus);
}
}
public void addItem(ShoppingListItem shoppingListItem)
{
requireNotNull(shoppingListItem, "Shopping list item");
ensureDraft("add items");
shoppingListItems.add(shoppingListItem);
shoppingListItem.setShoppingList(this);
}The service could check this too. But the entity does it regardless. Neither layer trusts the other.
Business Logic Belongs in the Entity#
The pattern I kept coming back to this week: the service coordinates, the entity decides.
Early on I wrote state transitions directly in the service. It worked, but the intent was scattered:
// Logic in service - wrong place
if (suggestion.getStatus() != Status.PENDING) throw ...
suggestion.setStatus(Status.APPROVED);
suggestion.setApprovedBy(approver);
suggestion.setApprovedAt(LocalDateTime.now());Moving that logic onto the entity makes the service a single readable line, and makes the transition atomic — all fields are set together or not at all:
// Logic in entity - one call, atomic
suggestion.approve(approver);The same pattern applies across the whole domain:
DishSuggestion.approve(approver)— validates status isPENDING, sets approver and timestampDishSuggestion.reject(approver, feedback)— requires feedback not blank, validates statusWeeklyMenu.publish(publisher)— setsPUBLISHED, records who published and whenShoppingList.finalizeShoppingList()— callsensureDraft(), setsFINALIZED, records timestampShoppingList.addItem(item)— callsensureDraft(), sets back-reference on item
The entity is not a dumb data bag. It knows its own rules.
The full lifecycle for a DishSuggestion looks like this:
stateDiagram-v2 PENDING --> APPROVED : approve(approver) PENDING --> REJECTED : reject(approver, feedback) APPROVED --> [*] REJECTED --> [*]
Interface Segregation on DAOs — Catching Mistakes at Compile Time#
WeeklyMenuService needs to look up dishes when building a menu slot. But should it be able to delete them? No.
So instead of injecting IDishDAO, it depends on IDishReader — a read-only interface:
// WeeklyMenuService - can only READ dishes
private final IDishReader dishReader;
private final IStationReader stationReader;
private final IUserReader userReader;
// DishService - full access
private final IDishDAO dishDAO;IDishReader exposes only getByID and getAll. IDishDAO extends it and adds create, update, delete.
classDiagram
class IDishReader {
+getById()
+getAll()
}
class IDishDAO {
+create()
+update()
+delete()
}
IDishDAO --|> IDishReader
class WeeklyMenuService
class DishService
WeeklyMenuService --> IDishReader
DishService --> IDishDAO
If someone injects IDishDAO into WeeklyMenuService by mistake, the code will not compile. The interface enforces the boundary — no runtime surprise, no accidental deletion.
Role-Based Authorization — Guard Methods#
Each service method loads the requesting user and passes it through a private guard:
// DishSuggestionService
User editor = userReader.getByID(editorId);
ensureIsKitchenStaff(editor);
// WeeklyMenuService
User editor = userReader.getByID(editorId);
requireHeadOrSousChef(editor);The guard methods are named as requirements:
// DishSuggestionService
private void ensureIsKitchenStaff(User user)
{
if (!user.isKitchenStaff())
{
throw new UnauthorizedActionException(
"Only kitchen staff can create dish suggestions"
);
}
}
// WeeklyMenuService
private void requireHeadOrSousChef(User user)
{
if (!user.isHeadChef() && !user.isSousChef())
{
throw new UnauthorizedActionException(
"Only head chef and sous chef can manage menus"
);
}
}The role-check helpers live on the User entity (isHeadChef(), isSousChef(), isKitchenStaff()). The service decides who is allowed to act. The entity knows what it is.
Validation Infrastructure#
Rather than repeating min/max bounds in every service, all validation goes through ValidationUtil:
// AllergenService
private void validateNames(String nameDA, String nameEN)
{
ValidationUtil.validateName(nameDA, "Name DA");
ValidationUtil.validateName(nameEN, "Name EN");
}
private void validateDescriptions(String descDA, String descEN)
{
ValidationUtil.validateDescription(descDA, "Description DA");
ValidationUtil.validateDescription(descEN, "Description EN");
}
// DishSuggestionService
private void validateCreateInput(DishSuggestionCreateDTO dto)
{
ValidationUtil.validateNotNull(dto, "Dish Suggestion");
ValidationUtil.validateId(dto.stationId());
ValidationUtil.validateName(dto.nameDA(), "Name");
ValidationUtil.validateDescription(dto.descriptionDA(), "Description");
ValidationUtil.validateRange(dto.targetWeek(), 1, 53, "Target week");
ValidationUtil.validateRange(dto.targetYear(), 2020, 2100, "Target year");
}validateName and validateDescription call validateText internally, which checks length and runs SAFE_TEXT_PATTERN. That pattern blocks characters with help of Regular Expressions, that do not belong in a dish name — one layer of protection against injection attacks. Named constants (NAME_MIN = 2, NAME_MAX = 100) mean there is one place to change a bound, not twenty.
What Worked Well#
Interface segregation on DAOs caught potential mistakes at compile time. WeeklyMenuService physically cannot call dishDAO.delete() — it only has IDishReader.
Entity business methods made services clean and readable. approve(), reject(), publish(), ensureDraft() turned multi-line service logic into single, atomic calls.
requireXxx() naming convention — private guard methods that read like requirements. requireHeadChef, requireNotEmpty, requireDraft. Clear intent at a glance.
ValidationUtil named methods — validateName() instead of repeating min/max in every service. Change NAME_MIN in one place.
The suggestion → dish mapping on approve — keeping the suggestion in the database for audit history while creating a new Dish entity in the dish bank was the right call. Two separate concerns, two separate entities.
What I Would Do Differently#
Define parameter conventions on day one. The rule is simple: userId from JWT, resource IDs from path, relations in DTO body. Writing it down once would have saved several refactor rounds.
Implement entity methods before writing services. User.update() was empty when UserService.update() was written. Silent incorrect behavior — no fields updated, no error thrown. Entity methods first, services second.
One DTO per use case from the start. Some DTOs had IDs that belonged in the path param, not the body. Define DTOs correctly before writing service signatures.
The service layer is almost done, and the structure now feels solid. Next week: wiring everything together through controllers, setting up server configuration with Javalin.
This is part 5 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time.
