Skip to main content

Designing and Implementing the Service Layer

·1727 words·9 mins
Morten Jensen
Author
Morten Jensen
Former chef with over 20 years in professional kitchens, now studying computer science
MiseOS Development - This article is part of a series.
Part 5: This Article

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:

ServiceResponsibility
AllergenServiceCRUD + EU allergen seeder (14 official allergens, bilingual DA/EN, double-seed protection)
DishServiceCRUD + activate/deactivate + available dishes grouped by station
DishSuggestionServiceSubmit, approve, reject, update — full suggestion lifecycle
AiServiceNormalize data, generate AI-powered dish suggestions based on station weather and ISO certification
DishTranslationServiceTranslate dish names and descriptions to English or a target language
WeeklyMenuServiceCreate menu, add/remove/update slots, translate, publish
StationServiceCRUD + name uniqueness
UserServiceRegister, login, update profile, change role/email/password, delete
IngredientRequestServiceCreate, approve, reject, filter by status and delivery date
ShoppingListServiceCreate, 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 is PENDING, sets approver and timestamp
  • DishSuggestion.reject(approver, feedback) — requires feedback not blank, validates status
  • WeeklyMenu.publish(publisher) — sets PUBLISHED, records who published and when
  • ShoppingList.finalizeShoppingList() — calls ensureDraft(), sets FINALIZED, records timestamp
  • ShoppingList.addItem(item) — calls ensureDraft(), 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 methodsvalidateName() 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.

MiseOS Development - This article is part of a series.
Part 5: This Article