Files
db-subclass-to-dto/EXERCISE.md
T
mostalive 08b52806de TestDataBuilders: ValidOrder()/ValidClient() with explicit Build(); After tests migrated
Domain objects in After-side tests are constructed only through the
builders, so the upcoming OrderLines evolution changes builder internals
instead of every test. Before keeps object initializers (legacy world).

yak: testdatabuilders-validordervalidclient-after-test-migration-91vl
2026-09-16 22:32:13 +01:00

6.9 KiB

Exercise: the discount that wouldn't map

You are in the "after" situation of this code base: the domain objects (Client, Order) are plain — no DbBase inheritance, no knowledge of the DbContext. Two hand-written mapping families lead out of the domain:

  • OrderDto / ClientDto — transport shapes consumed by the WebApp;
  • OrderData / ClientData — the data-only records the persistence framework stores (one row per object, object graph flattened to ids).

The before situation was Active Record: the domain object and the stored shape were one and the same class, so there was nothing to map. Now mapping is explicit — and that is where this exercise lives.

What you get

  • Order.TotalInCents — the order total, an integer in cents, persisted and mapped one-on-one into OrderDto and OrderData.
  • DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage) — a pure domain service. It returns a DiscountedOrder (a separate domain type, correlated to the order by OrderId) carrying the order's total, the percentage actually applied (0 when the total does not strictly exceed the barrier), and the discounted total. Rounding is half-up, in integer math.
  • Unit tests for all of the above already pass.
  • ValidOrder() / ValidClient() test data builders in the test project (tests/BeforeAfter.Tests/TestData/) — construct domain objects in tests through them, so tests survive domain evolution. Import their static factories with using static BeforeAfter.Tests.TestData.OrderBuilder; (and ...ClientBuilder;).

The challenge

The DiscountedOrder is not a one-on-one copy of the order, and it must not become one:

  • Order needs to learn the discounted total — but neither OrderDto nor OrderData may gain it. The discount is derived data: it lives in the domain only, and the storage schema stays untouched.
  • The DiscountedOrder.DiscountPercentage is not copied onto the order — it can be derived from the two totals (and TotalInCents never changes).

So the flow is: order → DiscountedOrder (calculate) → order (correlate + copy, dropping what is derivable) → persistence (which stores less than the domain now knows).

Work through the three steps below — each is small enough to hand to a coding agent as a single instruction. Run the tests after each step:

scripts/run-tests.sh

Step 1 — teach the order its discounted total

Add a single property DiscountedTotalInCents (int, default 0) to the domain Order in src/After/Order.cs. Do not touch OrderDto, OrderData, or the mappers: the discounted total must exist in the domain only. Acceptance: the solution builds and the full test suite still passes.

Step 2 — correlate a discounted order onto its order

Create tests/BeforeAfter.Tests/DiscountedOrderMappingTests.cs from the stub below and make the first test pass. The point is the correlation: the DiscountedOrder knows the id of the order it was calculated from — use it instead of assuming there is only one order around.

using After;
using static BeforeAfter.Tests.TestData.ClientBuilder;
using static BeforeAfter.Tests.TestData.OrderBuilder;

namespace BeforeAfter.Tests;

/// <summary>
/// EXERCISE — the mapping from a DiscountedOrder onto an Order is NOT
/// one-on-one. Make these tests pass, following EXERCISE.md step by step.
/// </summary>
public class DiscountedOrderMappingTests
{
    [Fact] // EXERCISE STEP 2
    public void A_discounted_order_can_be_correlated_onto_its_order()
    {
        var db = new DbContext();
        var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
        var client = ValidClient().WithName("Acme").WithOrder(order).Build();
        db.Save(client);
        db.Save(order);

        // 10 % for orders above €100:
        var discounted = DiscountCalculator.CalculateDiscount(
            order, discountBarrierCents: 100_00, discountPercentage: 10);

        // TODO (step 2): correlate the discounted order to the saved order and
        // copy the discounted total onto the order.
        // Hints: DiscountedOrder.OrderId identifies the order; the discount
        // percentage is deliberately NOT copied — it is derivable.

        // ... your code here ...

        Assert.Equal(135_00, order.DiscountedTotalInCents);
    }

    [Fact] // EXERCISE STEP 3
    public void Saving_a_discounted_order_keeps_the_stored_total_untouched()
    {
        var db = new DbContext();
        var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
        var client = ValidClient().WithName("Acme").WithOrder(order).Build();
        db.Save(client);
        db.Save(order);

        var discounted = DiscountCalculator.CalculateDiscount(
            order, discountBarrierCents: 100_00, discountPercentage: 10);

        // TODO (step 3): correlate + copy as in step 2, then save the updated
        // order through the context. Afterwards find the order's persisted
        // OrderData row (hint: db.Find<OrderData>(...)) and assert that the
        // stored total is STILL the original 150_00: the database knows
        // nothing about discounts — derived data lives in the domain only.

        // ... your code here ...

        OrderData row = null!;   // TODO (step 3): find the OrderData row for this order
        Assert.Equal(150_00, row.TotalInCents);
    }
}

Acceptance: the first test is green, the second still red.

Step 3 — save, and check what actually persisted

Make the second test pass: re-save the updated order through the context (the context refreshes the stored row in place), then assert on the persisted OrderData row. Note what you are not asserting: there is no discounted column to check — the row simply keeps the original total.

Acceptance: the whole suite is green — including the pre-existing tests, which guard exactly the invariants you must not break (the DTO and the persistence records still map the same fields as before).

Facilitator notes

  • Expected end state per step: (1) one new domain-only property; (2) a mapping fragment outside the Mappers class — correlation by id plus a field copy, dropping the derivable percentage; (3) db.Save(order) after which the OrderData row still shows TotalInCents == 150_00.
  • Discussion points: why two mapping families out of the domain (transport vs persistence) and why neither carries the discount; what an Active Record "before" world would have done here (the discounted value would have been a column, or nothing at all); what happens to DiscountedTotalInCents when the order travels to the WebApp and back (the DTO round-trip silently drops it — derived data must be recomputed, not transported).
  • The unit tests in DiscountCalculatorTests.cs pin the calculation contract (strict barrier, half-up rounding, validation); participants should not need to touch them.