Mert Özen Further With Every Line
Software Design UML and Modeling — #18

End-to-End Modeling: Taking the Leave Module from Requirements to Working Code

Mert Özen Nov 7, 2026 17 min 4 views
End-to-End Modeling: Taking the Leave Module from Requirements to Working Code

Diagrams pay off when they form a traceable chain from requirement to endpoint, from state machine to domain code and from ER to migration, and when they answer "which diagram changes?" before a change is made. In the series finale we take the leave module to .NET 10 API, EF Core and Angular 22 code, measure the impact of a half-day leave requirement with git diff and show the real bugs the diagrams caught along the way.

Version note: The diagrams in this post use UML (Unified Modeling Language) 2.5.1, C4 (Context, Containers, Components, Code) and Crow's Foot notation; the C# examples were built and run with .NET 10, Entity Framework Core (EF Core) 10.0.14 and a database server (SQL Server 2025), the Angular code with Angular 22.2.

Guess First: Which Diagrams Does a Half-Day Leave Change?

Over the series we drew six diagrams for the leave module of the HR Portal: use case, domain class diagram, the "submit the request" sequence diagram, state machine, ER (Entity-Relationship) diagram and the C4 container diagram. In this post we tie them all to working code in a single repo. Then a new item arrives from the business:

- R13. A single-day leave can be requested as a half day (morning or afternoon); it counts as 0.5 working day.

Before you open the code, guess: which of the six diagrams change and which stay as they are? Is a new use case needed, does the state machine get a new state, does a new container appear in C4? And how are requests 42, 43 and 44, already in the database, affected by this change? We will give the answer at the end of the post with the real git diff output.

The Start: Requirements Text and Repo Layout

Everything started with the requirements text in the series' second post. A shortened English version sits in the repo as docs/requirements.md, with items numbered R1, R2 and so on; these numbers are the key to traceability in diagrams, code comments and PRs (pull requests):

# Leave Request Module - Business Requirements (v1.0)

- R1. An employee creates a leave request: leave type, start and end date, optional reason. Draft first, then submit.
- R2. On submission the system counts working days (weekends and HR-defined public holidays excluded).
- R3. Annual leave cannot be submitted if the remaining balance is less than the requested working days.
- R4. An employee cannot have two active requests with overlapping dates.
- R6. A submitted request goes to the line manager, who approves or rejects (a reason is mandatory when rejecting).
- R7. Requests longer than 3 working days also need the department director's approval.
- R8. The balance is checked again at approval time.
- R9. Final approval deducts annual leave from the balance and writes the leave to the calendar system.
- R11. Pending requests can be cancelled any time; approved ones only before they start (HR can cancel later).

The repo layout is built around the idea that the diagram lives in the same place as the code:

docs/
    requirements.md
    diagrams/
        hr-style.iuml
        use-cases.puml
        domain-model.puml
        submit-leave-request.puml
        leave-request-states.puml
        containers.puml
        generated/
            er.mmd
src/
    HrPortal.Leave/          Domain/, Application/, Infrastructure/
    HrPortal.Api/            Program.cs, LeaveEndpoints.cs, Migrations/
web/
    hr-portal-web/           Angular 22
.github/
    pull_request_template.md
    workflows/diagrams.yml

There are two kinds of diagrams, and the distinction is deliberate. docs/diagrams/*.puml is drawn by hand, because it tells a decision or a question: which actor does what, which state leads to which. docs/diagrams/generated/er.mmd is generated from code, because its answer is already in the EF Core model: which table has which column. You read a hand-drawn diagram in review; you check a generated one in CI (Continuous Integration) for "is it up to date?". For brevity, Domain, Application and Infrastructure are three namespaces of a single project; the package diagram in post 7 explained how to split them into separate projects. The map that ties all the pieces together:

18-traceability-map-en.png

From Use Case to Endpoint

A use case diagram says what the system offers to the outside; in an API (Application Programming Interface) the counterpart is endpoints. The mapping is not one to one, but it must be traceable:

Use caseHTTP (Hypertext Transfer Protocol) endpointRequirement
UC-01 Request LeavePOST /leave-requests, POST /leave-requests/{id}/submitR1–R4
UC-02 Decide on Leave RequestPOST /leave-requests/{id}/approve, POST /leave-requests/{id}/rejectR6–R9
UC-03 Cancel Leave RequestPOST /leave-requests/{id}/cancelR11
UC-04 View Leave BalanceGET /employees/{number}/leave-balancesR3

UC-01 was split into two endpoints, because the requirements separate a draft from a submission and in the state machine these are two separate states (Draft and PendingManagerApproval). With Minimal APIs:

public static class LeaveEndpoints
{
    // Use case -> endpoint mapping (UC-01, UC-02, UC-03, UC-04)
    public static void MapLeaveEndpoints(this IEndpointRouteBuilder app)
    {
        var requests = app.MapGroup("/leave-requests");

        requests.MapPost("/", async (CreateLeaveRequest body, ILeaveRequestService service, CancellationToken ct) =>
        {
            var dto = await service.CreateAsync(body, ct);
            return TypedResults.Created($"/leave-requests/{dto.Id}", dto);
        });
        requests.MapPost("/{id:int}/submit", (int id, ILeaveRequestService s, CancellationToken ct) => s.SubmitAsync(id, ct));
        requests.MapPost("/{id:int}/approve", (int id, DecisionRequest body, ILeaveRequestService s, CancellationToken ct) => s.ApproveAsync(id, body, ct));
        requests.MapPost("/{id:int}/reject", (int id, DecisionRequest body, ILeaveRequestService s, CancellationToken ct) => s.RejectAsync(id, body, ct));
        requests.MapPost("/{id:int}/cancel", (int id, CancelRequest body, ILeaveRequestService s, CancellationToken ct) => s.CancelAsync(id, body, ct));
        requests.MapGet("/{id:int}", async (int id, ILeaveRequestService s, CancellationToken ct) =>
            await s.FindAsync(id, ct) is { } dto ? Results.Ok(dto) : Results.NotFound());

        app.MapGet("/employees/{number}/leave-balances", (string number, HrPortalDbContext db, CancellationToken ct) =>
            db.LeaveBalances
                .Where(b => db.Employees.Any(e => e.Id == b.EmployeeId && e.EmployeeNumber == number))
                .Join(db.LeaveTypes, b => b.LeaveTypeId, t => t.Id,
                    (b, t) => new LeaveBalanceDto(t.Code, b.Year, b.EntitledDays, b.UsedDays, b.EntitledDays - b.UsedDays))
                .ToListAsync(ct));
    }
}

Two of the use cases (UC-05 adjusting a balance and UC-06 leave policies) are outside this iteration. It is valuable to see that in the diagram rather than in the code: the use case diagram is the only place that also shows what has "not been done yet".

From Domain Model and State Machine to Domain Code

The LeaveRequest class from the domain class diagram goes into code with its attributes, operations and the DirectorApprovalThreshold = 3 constant. Every arrow in the state machine is a transition inside a method; every invalid arrow is an exception:

public class LeaveRequest
{
    public const int DirectorApprovalThreshold = 3;
    private readonly List<Approval> _approvals = [];

    // ...

    public bool RequiresDirectorApproval => WorkingDays > DirectorApprovalThreshold;

    // Draft --submit--> PendingManagerApproval
    public void Submit(int workingDays, DateTimeOffset now)
    {
        if (Status != LeaveStatus.Draft) throw new InvalidLeaveTransitionException(Status, "submit");
        WorkingDays = workingDays;
        SubmittedAt = now;
        Status = LeaveStatus.PendingManagerApproval;
    }

    // PendingManagerApproval --approve [WorkingDays > 3]--> PendingDirectorApproval
    // PendingManagerApproval --approve [WorkingDays <= 3]--> Approved
    // PendingDirectorApproval --approve--> Approved
    public void Approve(Employee approver, string? comment, DateTimeOffset now)
    {
        var step = CurrentStep("approve");
        _approvals.Add(new Approval(approver.Id, step, ApprovalDecision.Approved, comment, now));
        Status = step == ApprovalStep.Manager && RequiresDirectorApproval
            ? LeaveStatus.PendingDirectorApproval
            : LeaveStatus.Approved;
    }

    private ApprovalStep CurrentStep(string trigger) => Status switch
    {
        LeaveStatus.PendingManagerApproval => ApprovalStep.Manager,
        LeaveStatus.PendingDirectorApproval => ApprovalStep.Director,
        _ => throw new InvalidLeaveTransitionException(Status, trigger)
    };
}

The compositions (LeaveRequest *-- Approval) turn into ownership of the collection: _approvals cannot be changed from outside, an approval is added only through Approve and Reject. Note also that Department.DirectorId is int?; in the ER diagram post we saw that a cycle of two mandatory relationships made inserting the first row impossible, and made this end optional. The seed code uses the two-step insert from that post as well.

From Sequence Diagram to Service

The lifelines of the sequence diagram turn into classes and the messages into method calls: the HrPortal.Api endpoint, LeaveRequestService.SubmitAsync, IWorkingDayCalculator.CountWorkingDaysAsync, IBalanceService.HasEnoughBalanceAsync. I wrote the code in the order of the diagram and tried the behaviour of the first version end to end with a script. When submit was called again for the approved request #42, the response was (traceId shortened):

POST /leave-requests/42/submit -> 422
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Rule violated: balance-exceeded","status":422,"detail":"Not enough leave balance.","traceId":"00-13ca5c9c..."}

The request was already approved and deducted from the balance; on the second submission the balance is not enough, so it returns 422. Yet according to the state machine there is no Submit transition out of Approved; the right answer should have been "invalid transition". The bug came from the diagram, not from the code: in the sequence diagram, the working day calculation and the balance check came right after loading the request, while the status check was at the very end, inside request.Submit. The fix was the same line in the code and in the diagram:

 Service -> Db : load LeaveRequest #42
+Service -> Service : guard Status == Draft (else 409)
 Service -> Calc : CountWorkingDays(2026-12-21..25)
public async Task<LeaveRequestDto> SubmitAsync(int id, CancellationToken ct)
{
    var request = await LoadAsync(id, ct);
    // State machine first: submit is invalid in every status but Draft (409), no need to check the rules
    if (request.Status != LeaveStatus.Draft) throw new InvalidLeaveTransitionException(request.Status, "submit");

    var overlaps = await db.LeaveRequests
        .Where(r => r.EmployeeId == request.EmployeeId && r.Id != request.Id)
        .Where(r => r.Status != LeaveStatus.Draft && r.Status != LeaveStatus.Rejected && r.Status != LeaveStatus.Cancelled)
        .Where(r => r.Period.Start <= request.Period.End && request.Period.Start <= r.Period.End)
        .AnyAsync(ct);
    if (overlaps) throw new LeaveRuleViolationException("overlap", "Another active request overlaps these dates.");

    var days = await workingDays.CountWorkingDaysAsync(request.Period, ct);
    if (request.LeaveType.DeductsFromBalance &&
        !await balances.HasEnoughBalanceAsync(request.EmployeeId, request.LeaveType, request.Period.Start.Year, days, ct))
        throw new LeaveRuleViolationException("balance-exceeded", "Not enough leave balance.");

    request.Submit(days, clock.GetUtcNow());
    await db.SaveChangesAsync(ct);
    return await LoadDtoAsync(id, ct);
}

The diagrams also decide which error goes to which HTTP status. A transition that is not in the state machine is 409 Conflict, a violation of a rule from the requirements is 422 Unprocessable Entity, a missing record is 404:

// Transition not in the state machine -> 409, business rule violation -> 422, no record -> 404
public sealed class LeaveExceptionHandler(IProblemDetailsService problems) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception exception, CancellationToken ct)
    {
        (int status, string title) = exception switch
        {
            InvalidLeaveTransitionException => (StatusCodes.Status409Conflict, "Invalid status transition"),
            LeaveRuleViolationException e => (StatusCodes.Status422UnprocessableEntity, $"Rule violated: {e.Rule}"),
            NotFoundException => (StatusCodes.Status404NotFound, "Not found"),
            _ => (0, "")
        };
        if (status == 0) return false;

        http.Response.StatusCode = status;
        return await problems.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = http,
            ProblemDetails = new ProblemDetails { Status = status, Title = title, Detail = exception.Message },
            Exception = exception
        });
    }
}

From ER to EF Core and Back

The transformation rules from the ER diagram post are in HrPortalDbContext: composition as an FK (Foreign Key) with cascade, association as Restrict, a unique (LeaveRequestId, Step) index on Approvals, an identity starting at 42 for LeaveRequest.Id. The schema is created with migrations (dotnet ef migrations add InitialCreate, dotnet-ef from the local tool manifest in the repo) and applied with MigrateAsync when the API starts. The ER itself is not drawn by hand but generated from the same model; we added the MermaidErWriter from the ER post to the API project and gave it a small command-line entry:

// Diagram generated from code: dotnet run --project src/HrPortal.Api --no-launch-profile -- --er
if (args is ["--er"])
{
    var options = new DbContextOptionsBuilder<HrPortalDbContext>().UseSqlServer().Options;
    using var model = new HrPortalDbContext(options);
    Console.Write(MermaidErWriter.Write(model.Model));
    return;
}

--no-launch-profile is a detail but an important one: without it, dotnet run prints a "Using launch settings from ..." line to standard output, which ends up as the first line of er.mmd and breaks Mermaid. A piece of the generated file:

erDiagram
    Approvals {
        int Id PK
        int ApproverId FK
        nvarchar(500)? Comment
        datetimeoffset DecidedAt
        nvarchar(20) Decision
        int LeaveRequestId FK, UK
        nvarchar(20) Step UK
    }
    Departments {
        int Id PK
        int? DirectorId FK
        nvarchar(100) Name
    }

From C4 to a Running System and Angular

The counterparts of the boxes of the C4 container diagram in this repo: the Web App box is web/hr-portal-web, the HrPortal.Api box is src/HrPortal.Api, the HR Database box is the database in SQL Server. The worker, Redis and the message broker are not in this iteration; the calendar system is an implementation of ICalendarGateway that writes a log line. The boxes of the diagram without a counterpart in code are information too: C4 shows the target architecture, the repo shows today, and the difference is a backlog. Program.cs describes the inside of the container, that is, how the components from post 12 are wired together with DI (Dependency Injection):

builder.Services.AddDbContext<HrPortalDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("HrPortal"),
        sql => sql.MigrationsAssembly("HrPortal.Api")));
builder.Services.AddScoped<ILeaveRequestService, LeaveRequestService>();
builder.Services.AddScoped<IWorkingDayCalculator, WorkingDayCalculator>();
builder.Services.AddScoped<IBalanceService, BalanceService>();
builder.Services.AddScoped<ICalendarGateway, LoggingCalendarGateway>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<LeaveExceptionHandler>();
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));

I ran the API against SQL Server and played the scenario from the CASE file end to end with curl: Ada's annual leave for 21–25 December (#42), her manager Alan and her director Grace. The status and balance fields from the real output (the relevant part of the JSON, my notes in parentheses):

POST /leave-requests -> 201                  "id":42 ... "status":"Draft"
POST /leave-requests/42/submit -> 200        "workingDays":5,"status":"PendingManagerApproval"
POST /leave-requests/42/approve -> 200       "status":"PendingDirectorApproval" (approver E-0420)
POST /leave-requests/42/approve -> 422       "detail":"E-0420 is not the approver for this step."
POST /leave-requests/42/approve -> 200       "status":"Approved" (approver E-0007)
GET /employees/E-1001/leave-balances -> 200  [{"leaveType":"ANNUAL","year":2026,"entitledDays":14,"usedDays":11,"remainingDays":3}]
POST /leave-requests/42/submit -> 409        "detail":"Cannot submit a request in status Approved."
POST /leave-requests/43/submit -> 200        "workingDays":2,"status":"PendingManagerApproval"
POST /leave-requests/43/approve -> 200       "status":"Approved" (approver E-0420)
POST /leave-requests/44/submit -> 422        "title":"Rule violated: balance-exceeded"

Every line corresponds to a diagram element. 5 working days went to the director step because of R7 (the choice in the state machine), the 2-day excuse leave (#43) was approved directly (the [else] branch), the wrong approver got 422 (R6), the balance dropped to 14 − 6 − 5 = 3 (R9), the new four-day request (#44) hit R3, and resubmitting an approved request got 409 because it is not in the state machine.

On the Angular side there is the screen seen by the use case's actor, the Employee. The API contract is in a service, enums in string unions:

// UML enumeration LeaveStatus -> string union (the API returns enums as strings)
export type LeaveStatus =
  | 'Draft'
  | 'PendingManagerApproval'
  | 'PendingDirectorApproval'
  | 'Approved'
  | 'Rejected'
  | 'Cancelled';

@Injectable({ providedIn: 'root' })
export class LeaveRequestApi {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = '/leave-requests';

  // UC-01: POST /leave-requests
  create(body: CreateLeaveRequest): Observable<LeaveRequestDto> {
    return this.http.post<LeaveRequestDto>(this.baseUrl, body);
  }

  // UC-01: POST /leave-requests/{id}/submit
  submit(id: number): Observable<LeaveRequestDto> {
    return this.http.post<LeaveRequestDto>(`${this.baseUrl}/${id}/submit`, {});
  }
}

The form component sends the first two messages of the sequence diagram: create a draft first, then submit it. The DateRange invariant "the end cannot be before the start" is repeated on the client as a validator; not instead of the server-side check, but for early feedback to the user:

// The client-side counterpart of the DateRange invariant (End >= Start)
function dateOrder(group: AbstractControl): ValidationErrors | null {
  const { start, end } = group.value as { start: string; end: string };
  return start && end && end < start ? { dateOrder: true } : null;
}

export class LeaveRequestFormComponent {
  // ...

  // Sequence diagram: create (Draft) -> submit (PendingManagerApproval)
  protected send(): void {
    const v = this.form.getRawValue();
    const body: CreateLeaveRequest = { employeeNumber: 'E-1001', ...v, reason: v.reason || null };
    this.busy.set(true);
    this.error.set(null);
    this.api
      .create(body)
      .pipe(switchMap((draft) => this.api.submit(draft.id)))
      .subscribe({
        next: (r) => {
          this.result.set(r);
          this.busy.set(false);
        },
        error: (e: HttpErrorResponse) => {
          // ProblemDetails: 422 business rule, 409 invalid transition
          this.error.set(e.error?.detail ?? e.message);
          this.busy.set(false);
        },
      });
  }
}

ng build completed without errors (initial bundle 201 kB). When I opened the app with ng serve and a proxy to the API and sent a single-day annual leave for 27 November from the form, the screen showed the request number, "1 working day(s)" and "status PendingManagerApproval"; when I sent the same form a second time, the message of R4 came back: "Another active request overlaps these dates." That second attempt also had a side effect: the draft was created but could not be submitted, so an orphan Draft remained in the database. Since R1 allows drafts this is not a bug, but the question "when are drafts cleaned up?" is not in the requirements; it is an agenda item for the next meeting with the business.

Repo Rules: PR Template and CI

The only reliable way to keep diagrams up to date is to make the update a step of the PR. The PR template:

## What changed
<!-- Requirement / issue id, e.g. R13 -->

## Model impact
- [ ] Requirements (`docs/requirements.md`) updated
- [ ] Hand-drawn diagrams updated (`docs/diagrams/*.puml`) or "no change" explained below
- [ ] Generated ER diagram refreshed (`dotnet run --project src/HrPortal.Api --no-launch-profile -- --er`)
- [ ] EF Core migration added for schema changes

Diagrams checked: use case / domain model / sequence / state machine / C4

The "no change explained below" item is deliberate: saying that a diagram does not change is a decision too, and the reviewer needs to see it. The template reminds you of the hand-drawn diagrams; CI enforces the generated ER. We added a step to the workflow from the PlantUML post:

      - name: Generated ER diagram must be up to date
        run: |
          dotnet run --project src/HrPortal.Api --no-launch-profile -- --er > docs/diagrams/generated/er.mmd
          git diff --exit-code docs/diagrams/generated/er.mmd

I ran this step locally in two situations. In a clean repo the exit code is 0. When the model changes and er.mmd stays stale, git diff --exit-code prints the difference and returns 1:

--- a/docs/diagrams/generated/er.mmd
+++ b/docs/diagrams/generated/er.mmd
@@ -24,14 +24,15 @@ erDiagram
     LeaveBalances {
         int Id PK
         int EmployeeId FK, UK
-        int EntitledDays
+        decimal(4,1) EntitledDays

I did not run the workflow on GitHub; I ran the steps' commands locally in the same order.

The Change Scenario: Half-Day Leave

R13 has arrived. This is where the real value of the model shows: before touching the code we ask each diagram "do you change?". Use case: no new goal, the Employee still requests leave; an alternative flow is added to the UC-01 specification. State machine: a half-day request goes through the same states. C4: no new container or relationship. Domain model: changes, WorkingDays is no longer an integer and which half of the day the request covers is a new attribute. Sequence: the messages are the same but the signature of CountWorkingDays changes. ER: column types and a new column. Compare with the list you guessed; then the real diff:

 docs/diagrams/domain-model.puml                    |  17 +-
 docs/diagrams/generated/er.mmd                     |   7 +-
 docs/diagrams/submit-leave-request.puml            |   2 +-
 docs/requirements.md                               |   1 +
 .../20261107201226_AddHalfDayLeave.Designer.cs     | 358 +++++++++++++++++++++
 .../Migrations/20261107201226_AddHalfDayLeave.cs   |  90 ++++++
 .../Migrations/HrPortalDbContextModelSnapshot.cs   |  20 +-
 src/HrPortal.Leave/Application/Contracts.cs        |  16 +-
 .../Application/LeaveRequestService.cs             |   6 +-
 src/HrPortal.Leave/Domain/Enums.cs                 |   1 +
 src/HrPortal.Leave/Domain/LeaveBalance.cs          |  10 +-
 src/HrPortal.Leave/Domain/LeaveRequest.cs          |  11 +-
 .../Infrastructure/HrPortalDbContext.cs            |   5 +-
 src/HrPortal.Leave/Infrastructure/Services.cs      |  11 +-
 .../src/app/leave/leave-request-api.ts             |   5 +
 .../src/app/leave/leave-request-form.ts            |   6 +
 16 files changed, 526 insertions(+), 40 deletions(-)

use-cases.puml, leave-request-states.puml and containers.puml are not on the list; the guess holds. The domain model's diff:

 class LeaveRequest {
   Period : DateRange
-  WorkingDays : int
+  DayPart : DayPart
+  WorkingDays : decimal
   Reason : string [0..1]
   Status : LeaveStatus
-  + Submit(workingDays : int, now : DateTimeOffset)
+  + Submit(workingDays : decimal, now : DateTimeOffset)
@@
 class LeaveBalance {
   Year : int
-  EntitledDays : int
-  UsedDays : int
-  /RemainingDays : int
+  EntitledDays : decimal
+  UsedDays : decimal
+  /RemainingDays : decimal
 }
@@
+enum DayPart {
+  FullDay
+  Morning
+  Afternoon
+}

In the code the new rule goes into the constructor, because "a half day only for a single-day leave" is an invariant and an invalid request should never come into existence:

public LeaveRequest(int employeeId, int leaveTypeId, DateRange period, DayPart dayPart, string? reason, DateTimeOffset now)
{
    // R13: a half day only for a single-day leave
    if (dayPart != DayPart.FullDay && period.Start != period.End)
        throw new LeaveRuleViolationException("half-day-single-date", "A half day must start and end on the same date.");
    EmployeeId = employeeId;
    LeaveTypeId = leaveTypeId;
    Period = period;
    DayPart = dayPart;
    // ...
}

The migration held a decision the model could not see. dotnet ef migrations add AddHalfDayLeave generated defaultValue: "" for the new DayPart column. I applied the migration as is to a copy of the v1 data and looked at the existing requests:

DayPart Requests
------- --------
 3

(1 rows affected)

The DayPart of the three old requests is an empty string. The API did not even notice: EF Core's enum-to-string converter silently turned the unknown value into the enum's default (FullDay) and everything looked right in the JSON. But anyone writing a reporting query would hit the empty string. The data did not say what the model said. I fixed the migration by hand, restored the database from the v1 backup and applied it again:

migrationBuilder.AddColumn<string>(
    name: "DayPart",
    table: "LeaveRequests",
    type: "nvarchar(10)",
    maxLength: 10,
    nullable: false,
    defaultValue: "FullDay"); // existing requests are full days
DayPart Requests
------- --------
FullDay 3

Then the new scenario. A two-day half-day request is rejected in the constructor; a half-day annual leave for the morning of 27 November is submitted, approved by the manager, and the balance drops from 3 to 2.5:

POST /leave-requests -> 422                  "detail":"A half day must start and end on the same date."
POST /leave-requests -> 201                  "id":45 ... "dayPart":"Morning","workingDays":0.0,"status":"Draft"
POST /leave-requests/45/submit -> 200        "workingDays":0.5,"status":"PendingManagerApproval"
POST /leave-requests/45/approve -> 200       "status":"Approved" (approver E-0420)
GET /employees/E-1001/leave-balances -> 200  [{"leaveType":"ANNUAL","year":2026,"entitledDays":14.0,"usedDays":11.5,"remainingDays":2.5}]

18-change-impact-en.png

We could derive this table before writing any code, just by looking at the diagrams. The three unchanged diagrams draw the boundary of the change: no new actor, no new state, no new external system, so the risk is in the domain and data layers. Here the model worked not as documentation but as a change analysis tool.

Common Pitfalls

Drawing the diagram after the code. The guard order bug would have been caught in review had we read the sequence diagram together with the code. A diagram is valuable when it is the answer to a question asked before the code is written; a diagram drawn afterwards is a copy of the code and copies its bugs too.

Drawing by hand what can be generated. Had we kept the ER by hand, the decimal(4,1) change would most likely not have reached the diagram until the next sprint. Generate everything that can be generated from code and check it in CI; save hand drawing for diagrams that tell a decision.

Not writing down deliberate differences between diagram and code. Post 11's state machine carries an [hasEnoughBalance] guard on every Approve arrow; this implementation checks the balance only at the final approval, while deducting (LeaveBalance.Deduct throws on an insufficient balance). Either the code should follow the diagram or the diagram should be updated; if neither, the difference must be written down somewhere.

Applying a migration without reading it. The defaultValue: "" example: the model was right, the diagram was right, and the generated code silently corrupted the data. In every PR with a schema change, a reviewer must read the migration's Up method.

Treating the model as a finished document. The worker and the broker from C4 have no counterpart in this repo, UC-05 and UC-06 were not built, and the "orphan drafts" question is open. The model lives as long as it shows the difference between what the system is today and what it is meant to be.

A Small Experiment

★☆☆ Pick a requirement in your own project and ask six diagrams one by one "do you change?", as in this post. After writing down your answers, make the change and compare with git diff --stat. How many diagrams did you guess right?

★★☆ Align the implementation in this post with post 11's state machine: apply the [hasEnoughBalance] guard at the manager's approval too, and write a unit test for every arrow in the diagram (as in the series' state machine post). Which tests are red with the current code?

★★★ Add the UC-05 Adjust Leave Balance use case end to end: a specification in the Cockburn template, BalanceAdjustment in the domain model, a sequence diagram, a migration, an endpoint and an Angular screen for the HR specialist. Open the PR with the template's checklist and also write down which diagram did not change and why.

With this post the UML and Modeling series ends. Over eighteen posts we carried a leave request from requirements text to use cases, to class and object diagrams, to sequence and activity diagrams, to a state machine, to component and deployment diagrams, to C4, to ER and to diagrams-as-code tools, and finally tied it to a running API and an Angular screen. From here there are two natural paths: the upcoming Requirements Analysis series on how to gather and write the requirements themselves, and the Architecture Decisions series on documenting these diagrams together with architecture decisions. Whichever you choose, before drawing the next diagram, ask: which question does this diagram answer?