The best technical solutions I’ve worked on didn’t arrive fully formed. They emerged gradually, one honest step at a time. A recent project on inventory control is a good example of how that progression plays out in practice.

Starting from the Business, Not the Database

The problem was familiar: track inventory quantities for products and lots. Quantities available to sell, due, on hand, and so on. The instinct on many projects is to jump straight to the data model: figure out the tables, define the schema, then build outward from there.

We didn’t do that.

Instead, we started with event storming. We gathered around a whiteboard and mapped the events that affect inventory: a lot arriving, units being sold, items being damaged. We asked what data each event carries and how it changes what we know about available quantities. That clarity was the foundation on which everything else was built.

From Events to Expectations: BDD

With the event model in hand, we moved to behavior-driven development. Using Given-When-Then scenarios, we described the expected outcomes for each event: given a lot is received, when units are sold, then the available quantity decreases accordingly.

I tailored the syntax to C# since I was both writing and maintaining the specs. Some frameworks let you write specs in plain English and map them to underlying code, but for this project, I wanted the specs to live alongside the implementation without an extra translation layer. The result was a set of readable, executable specs that described the inventory logic in terms the business recognized.

One spec traced the full lifetime of a lot: from creation to zero quantity, through sales, damage, adjustments, and so on. From that initial sweep, I refined the scenarios to capture the nuances of individual events and their effects. By the time we had good coverage, the team had a shared, concrete understanding of how inventory worked.

The spec below traces one scenario: a lot purchased and sold in the same unit of measure, through the full lifecycle from CREATED to RECEIVED to SOLD to PICKED to BILLED, with quantity assertions at both the item and lot levels at each step.

[Fact]
public void purchased_and_selling_in_PRIMARY_pack_size()
{
    given_a_purchased_item(
        purchaseOrderNumber: "100",
        itemId: _itemId,
        lineNumber: "1",
        quantity: 10,
        packSizes: new LotPackSizes(new PackSizeVO(Guid.NewGuid(), _caseUoM, null, 1))
            .RebuildWithPurchased(10));

    when_lot_is_CREATED_for(_quantityOrdered, "2023-08-30");

    then_expect_changes_to_ITEM_quantity(_ =>
    {
        _.DueIn = 10;
        _.AvailableToSell = 10;
    });
    and_expect_NO_changes_to_ITEM_quantity(_ =>
    {
        _.OnHand = 0;
        _.Allocated = 0;
        _.Unallocated = 0;
        _.Oversold = 0;
    });

    then_expect_changes_to_LOT_quantity("1001", _ =>
    {
        _.DueIn = 10;
        _.AvailableToSell = 10;
    }, _scheduledReceivingDate);
    and_expect_NO_changes_to_LOT_quantity("1001", _ =>
    {
        _.OnHand = 0;
        _.Allocated = 0;
    }, _scheduledReceivingDate);

    when_lot_is_RECEIVED(quantity: 10, "2023-08-30");

    then_expect_changes_to_ITEM_quantity(_ =>
    {
        _.OnHand = 10;
        _.DueIn = 0;
    });
    and_expect_NO_changes_to_ITEM_quantity(_ =>
    {
        _.AvailableToSell = 10;
        _.Allocated = 0;
        _.Unallocated = 0;
        _.Oversold = 0;
    });

    then_expect_changes_to_LOT_quantity("1001", _ =>
    {
        _.OnHand = 10;
        _.DueIn = 0;
    }, _receivedDate);
    and_expect_NO_changes_to_LOT_quantity("1001", _ =>
    {
        _.AvailableToSell = 10;
        _.Allocated = 0;
    }, _receivedDate);

    when_item_units_are_SOLD(quantity: 5, _caseUoM);

    then_expect_changes_to_ITEM_quantity(_ =>
    {
        _.AvailableToSell = 5;
        _.Unallocated = 5;
    });
    and_expect_NO_changes_to_ITEM_quantity(quantity =>
    {
        quantity.OnHand = 10;
        quantity.Allocated = 0;
        quantity.DueIn = 0;
        quantity.Oversold = 0;
    });

    when_lot_units_are_PICKED(quantity: 5, "2023-08-30");

    then_expect_changes_to_ITEM_quantity(_ =>
    {
        _.Allocated = 5;
        _.Unallocated = 0;
    });
    and_expect_NO_changes_to_ITEM_quantity(_ =>
    {
        _.OnHand = 10;
        _.DueIn = 0;
        _.AvailableToSell = 5;
        _.Oversold = 0;
    });

    then_expect_changes_to_LOT_quantity("1001", _ =>
    {
        _.AvailableToSell = 5;
        _.Allocated = 5;
    }, _receivedDate);
    and_expect_NO_changes_to_LOT_quantity("1001", _ =>
    {
        _.OnHand = 10;
        _.DueIn = 0;
    }, _receivedDate);

    when_lot_units_are_BILLED(quantity: 5, "2023-09-01");

    then_expect_changes_to_ITEM_quantity(_ =>
    {
        _.OnHand = 5;
        _.Allocated = 0;
    });
    and_expect_NO_changes_to_ITEM_quantity(_ =>
    {
        _.DueIn = 0;
        _.AvailableToSell = 5;
        _.Unallocated = 0;
        _.Oversold = 0;
    });

    then_expect_changes_to_LOT_quantity("1001", _ =>
    {
        _.AvailableToSell = 5;
        _.Allocated = 0;
    }, _billedDate);
    and_expect_NO_changes_to_LOT_quantity("1001", _ =>
    {
        _.OnHand = 5;
        _.DueIn = 0;
    }, _billedDate);
}

The method names read like business language: when_lot_is_CREATED_for, when_lot_is_RECEIVED, when_item_units_are_SOLD. The UPPERCASE portions highlight the domain event, making the event-driven nature of the system visible at a glance. The assertions split between what should change (then_expect_changes_to_ITEM_quantity) and what should not change (and_expect_NO_changes_to_ITEM_quantity). That separation makes regressions easier to spot and communicates full intent, not just the happy path.

The Simplest Implementation That Could Work

With all the specs defined, I moved to implementing them. And I did the simplest thing possible.

A single command handler. A switch statement on event type. All the calculations inline. Results pushed to an in-memory dictionary.

Yes, in-memory. Nothing persisted in a database. Every time the backend restarted, the data was gone.

That was fine. We weren’t optimizing for production durability. We were optimizing for understanding. The specs drove the logic, the logic drove the calculations, and the calculations showed up in the UI. Stakeholders could see the results. The team could validate the workflow. We could uncover things that hadn’t come up in event storming.

All of that happened before we touched a database schema.

Stepping Inward: Persistence Without Refactoring Logic

Once the calculations were right and validated, we took the next step: persist the data so it survived a backend restart. By that point, I had started using Marten, a .NET library that supports both document storage and event sourcing.

The change was smaller than you might expect. We replaced the in-memory dictionary with a document saved to the database. That’s it. The handler didn’t change. The specs didn’t change. The Given-When-Then scenarios still passed. We just swapped the storage mechanism.

That’s one of the underrated benefits of driving development from specs: the specs describe behavior, not implementation. When the implementation changes, the behavior contract stays intact.

The Final Refactor: Doing It Properly with Projections

Sometime later, after learning more about Marten’s event sourcing features, I went back and refactored the implementation the right way.

Instead of a single handler with a switch statement, I created a Marten projection. Each event got its own Apply method. The calculations moved from the long handler into these focused, single-responsibility methods. The read model was now produced by the projection as events were processed.

It was a cleaner design. It used the library as intended. And every spec still passed.

That last point matters. The refactoring was textbook: changing the underlying implementation without changing the externally observable behavior. The BDD specs defined observable behavior, so they were the safety net for the refactor.

What Gradual Progression Makes Possible

When new events surfaced as we learned more about the business, adding them was straightforward: update the spec, implement the Apply method, done. The structure supported change.

When a new team member joined with no Marten or event sourcing experience, he was productive quickly. He could read the specs, model a new scenario after an existing one, find the relevant Apply method in the projection, and implement the feature. He didn’t need to understand the full system first. He needed just enough, and the specs gave him that.

The progression also meant we made technology choices when we had enough information to make them well. We didn’t commit to event sourcing on day one. We committed to understanding the business first, then reached for the technology that fit.

The Shape of the Journey

The path went from event storming to BDD specs to a simple in-memory implementation to document persistence to a proper Marten projection. Each step was the simplest next thing, built on genuine understanding from the step before.

The lesson learned is that “gradual” isn’t a compromise. It’s a strategy. Starting simple, validating early, and adding complexity only when earned tends to produce better designs than trying to get everything right upfront.

Leave a Reply

Trending

Discover more from Claudio Lassala's Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading