Published on

Consumer-expected, need-driven behavior development

Authors
#1

This article is based on previous one about the invisible role-switching problem and Need-Driven Design.

It is highly recommended to read it first, as it provides important context and background information.

#2

This article tries to illustrate TDD/3S worfklow.

Please bear in mind that we're simulating a worfklow that usually be pretty slick and smooth, considering the experience of the engineer.

Even though it might seem a bit verbose, heavy "on the screen", all those small steps are usually done in seconds, sometimes even less.

Clean slate

Imagine that this time Hank is aware of the invisible role-switching problem and Need-Driven Design.

He also understands the important role of interfaces.

In this alternative round, he will undergo the same sequence of events: implementing a feature, an interruption, and then coming back to finish the feature.

This time, however, he will approach the task differently.

Nothing lives in the vacuum, so Hank asked his colleague Tom about the requirements, from the consumer perspective.

Tom works on the web UI, so he will need to interact over HTTP with the web service to activate the tour.

Hank, as a capability provider, asked about the front-end perspective, expected behavior and needs.

They agreed upon the following behavior:

  • given a published tour does not exist, when activating it, then returns 404 Not Found
  • given a published tour exists, when activating it, then returns 200 OK and activates the tour
  • given a published tour is already active, when activating it, then returns 409 Conflict

Because they both are technical folks, they used language of the problem domain.

As this feature is not existing, they also agreed that a new endpoint will be created: POST /tours/{tourId}/activate.

Initially, to establish something working, both Hank and Tom decided to work on fake published tours - just to have something working end-to-end.

They mapped three identifiers to three different scenarios:

  • tour-404 - non-existing published tour
  • tour-409 - already active published tour
  • any other identifier - inactive published tour

HTTP interaction - consumer perspective

Hank started by specifying the expected behavior: given a published tour does not exist, when activating it, then returns 404 Not Found, from the consumer perspective, using tests.

[Fact]
public async Task given_non_existing_published_tour_when_activating_it_then_returns_404()
{
    // given
    var NON_EXISTING_TOUR_ID = "tour-404";
    var client = PublishedToursWebService.CreateHttpClient();

    // when
    var response = await client.PostAsync($"/tours/{NON_EXISTING_TOUR_ID}/activate", null);
    // then
    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

He ran it, which then failed, as expected.

"Time to satisfy the specification", thought Hank.

"But probably I should create an interface for repository to load published tours first?", he pondered.

"Also, I know that there must be a geotracking session created when a tour is activated, so I should create an interface for that as well", he continued.

Fortunately, Hank read 3S article recently, so he decided to follow that workflow.

Simplify comes after satisfying the specification, so he focused on satisfying the specification first.

"Maybe I could put the implementation next to the test, focusing on deliverying working behavior first?", he wondered.

By doing so, he enabled pretty fast feedback loop, as everything was in one place.

public static class PublishedToursWebService
{
    public static WebApplicationFactory<Program> WithPublishedToursActivation()
    {
        return new WebApplicationFactory<Program>()
            .WithWebHostBuilder(builder =>
            {
                builder.Configure(app =>
                {
                    app.MapPost("/tours/{tourId}/activate", (string tourId) =>
                    {
                        if (tourId == "tour-404")
                        {
                            return Results.NotFound();
                        }

                        throw new NotImplementedException();
                    });
                });
            });
    }
}

The specification through test was still failing, but now Hank had a working implementation to build upon, so he adjusted the test to use this handly, local WebApplicationFactory.

[Fact]
public async Task given_non_existing_published_tour_when_activating_it_then_returns_404()
{
    // given
    var NON_EXISTING_TOUR_ID = "tour-404";
    var client = PublishedToursWebService
        .WithPublishedToursActivation()
        .CreateHttpClient();
    
    // when
    var response = await client.PostAsync($"/tours/{NON_EXISTING_TOUR_ID}/activate", null);
    // then
    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

After running the test, it passed successfully.

HTTP interaction - next specifications

So now it is the time to decide: specify further or simplify?

Remember dear Reader, that simplification also means cleaning up, refactoring, improving the design.

Hank decided to move on with progressing on provided capability of activating published tours.

He proceeded to the next specification: given a published tour is already active, when activating it, then returns 409 Conflict.

[Fact]
public async Task given_already_active_published_tour_when_activating_it_then_returns_409()
{
    // given
    var ALREADY_ACTIVE_TOUR_ID = "tour-409";
    var client = PublishedToursWebService
        .WithPublishedToursActivation()
        .CreateHttpClient();

    // when
    var response = await client.PostAsync($"/tours/{ALREADY_ACTIVE_TOUR_ID}/activate", null);
    // then
    Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}

He ran it, which then failed, as expected.

"Time to satisfy the specification", thought Hank again.

He went to newly added local implementation and added the necessary logic to satisfy the specification.

app.MapPost("/tours/{tourId}/activate", (string tourId) =>
{
    if (tourId == "tour-404")
    {
        return Results.NotFound();
    }
    if (tourId == "tour-409")
    {
        return Results.Conflict();
    }
    throw new NotImplementedException();
});

After executing the specification through test, it passed successfully.

Satisfy or simplify?

Hank decided to proceed with the last specification: given a published tour exists, when activating it, then returns 200 OK and activates the tour.

[Theory]
[InlineData("tour-123")]
[InlineData("tour-xyz")]
[InlineData("tour-abc")]
public async Task given_existing_published_tour_when_activating_it_then_returns_200_and_activates_tour(string EXISTING_TOUR_ID)
{
    // given
    var client = PublishedToursWebService
        .WithPublishedToursActivation()
        .CreateHttpClient();
    
    // when
    var response = await client.PostAsync($"/tours/{EXISTING_TOUR_ID}/activate", null);
    // then
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

He ran it, which then failed, as expected.

"Time to satisfy the specification", thought Hank once more.

Yet again, he jumped to HTTP handler developed locally, close to the specification, and added the necessary logic to satisfy the specification.

app.MapPost("/tours/{tourId}/activate", (string tourId) =>
{
    if (tourId == "tour-404")
    {
        return Results.NotFound();
    }
    if (tourId == "tour-409")
    {
        return Results.Conflict();
    }
    return Results.Ok();
});

All specifications were green!

Simplify!

Ok, now it order to expose the capability properly, Hank needed to move the implementation from local, test project to the actual web service project.

So he took the implementation from local HTTP handler and moved it to Program.cs, which is kind of copy-and-paste operation (or if you prefer, dear Reader, IDE shortcut acrobatics).

He ran all specs, which were still green.

Hank also removed unused WithPublishedToursActivation which stopped being used.

Specs executed again, still green.

"Any further simplifying possible?", wondered Hank.

He decied that this capability, even though not completely ready, is good enough for Tom to start using it and getting feedback.

Changes were intergrated with the main branch which then got deployed to dev environment.

His UI development comrade, Tom, was able to start using the new endpoint right away, simulating different scenarios using the agreed-upon identifiers.

"Pushing unfinished work?", others were not so sure about it.

"It literally makes no sense to ship some mocked behavior to dev environment, doesn't it?", some were wondering.

Interrupting, interrupting never changes?

As we know from previous article, interruptions are part of software development life (is it normal? you might be interested in The ambiguity of team work article).

Hank was asked to write a documentation related to migrations.

He postponed the published tours activation feature and focused on writing the documentation.

During his documentation writing, Tom was able to progress with the UI part of the feature.

Let's imagine that Hank finished the documentation and got back to his work on published tours activation feature.

Three specifications were already in place, driven by the capability-exposed-over-HTTP consumer perspective.

Current implementation is quite, faked, if we could say so.

Hank faked it up to the point where he could share results of his work with Tom as soon as possible.

Now it's time to put a designer's hat and think about the system needs - deciding whether published tour exists, whether it is active - won't be purely based on input identifier.

This information will come from some durable place, possibly.

Currently, the system uses Postgres database, so probably that would be a good place to start.

But wait a sec - is this knowledge easily available for HTTP handler? Hm, no. It is not.

Hank played a role of the system designer for a while, now it's capability provider turn - HTTP handler...

...Which actually becomes a consumer, requiring a capability for loading published tours.

As Hank framed his reasoning by "becoming" a HTTP handler, he can specify the needs in the form of a required capability.

This specification happens in the test, of course.

[Fact]
public async Task given_non_existing_published_tour_when_activating_it_then_returns_404()
{
    // given
    var NON_EXISTING_TOUR_ID = "tour-404";
    var publishedTourRepository = Substitute.For<IGetPublishedTour>(); // ❌ bzzzzzt, does not compile!
    publishedTourRepository
        .GetPublishedTourById(NON_EXISTING_TOUR_ID)
        .Returns(Task.FromResult<PublishedTourToActivate?>(null)); // ❌ bzzzzzt, does not compile!

    var client = PublishedToursWebService
        .WithFake(publishedTourRepository)
        .CreateHttpClient();

    // when
    var response = await client.PostAsync($"/tours/{NON_EXISTING_TOUR_ID}/activate", null);
    
    // then
    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

He tried running it, but it did not even compile!

Now, Hank-the-HTTP-handler definitely has a proper demand to require this interface (a capability) to be produced.

In a way, it gets pulled into existence by the HTTP handler's need.

To tighten the feedback loop, it gets added in the same test project, close to the specification.

public interface IGetPublishedTour
{
    Task<PublishedTourToActivate?> GetPublishedTourById(string tourId);
}

public record PublishedTourToActivate(string Id, bool IsActive);

Now, at least it compiles.

But there's one important detail to consider - from the HTTP handler perspective, coordinating the workflow, whenever a published tour cannot be activated, nothing should be saved.

Shouldn't this be specified as well?

[Fact]
public async Task given_non_existing_published_tour_when_activating_it_then_returns_404()
{
    // given
    var NON_EXISTING_TOUR_ID = "tour-404";
    var publishedTourRepository = Substitute.For<PublishedToursRepository>(); // 👈 new interface!
    publishedTourRepository
        .GetPublishedTourById(NON_EXISTING_TOUR_ID)
        .Returns(Task.FromResult<PublishedTourToActivate?>(null));

    var client = PublishedToursWebService
        .WithFake(publishedTourRepository)
        .CreateHttpClient();

    // when
    var response = await client.PostAsync($"/tours/{NON_EXISTING_TOUR_ID}/activate", null);

    // then
    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    await publishedTourRepository
        .DidNotReceive()
        .Save(Arg.Is<PublishedTourToActivate>(x => x.Id == NON_EXISTING_TOUR_ID && x.IsActive == true)); // 👈 new expectation which does not compile ❌!
}

There is a new demand, time to pull, again.

public interface ISavePublishedTour // 👈 new capability added!
{
    Task Save(PublishedTourToActivate publishedTour);
}

public interface PublishedToursRepository : IGetPublishedTour, ISavePublishedTour;

Now everything compiles again.

He runs the specification, which fails, as expected.

"Time to satisfy the specification", thought Hank yet again.

Specify, satisfy, simplify - round two

He jumped to the HTTP handler implementation and added the necessary logic to satisfy the specification.

Before doing so, this PublishedToursRepository needed to be moved into Program.cs file to make it compile.

app.MapPost("/tours/{tourId}/activate", async (
    [FromRoute] string tourId,
    [FromServices] PublishedToursRepository publishedTourRepository
) =>
{
    var publishedTour = await publishedTourRepository.GetPublishedTourById(tourId);
    if (publishedTour is null)
    {
        return Results.NotFound();
    }
    if (tourId == "tour-409") // 👈 this remained untouched
    {
        return Results.Conflict();
    }

    await publishedTourRepository.Save(publishedTour with { IsActive = true });

    return Results.Ok(); // 👈 this remained untouched
}
);

After executing the specification through test, it passed successfully.

Hank decided to use "manual mutation testing" to verify the usefulness of the newly added specifications.

He manually changed publishedTour is null check to publishedTour is not null in the HTTP handler implementation.

Executed specification fails, as expected.

He reverts the change, runs specifications again, which pass successfully.

Hank asks himself: "Any simplifying possible?"

He decided that for now, the design is good enough.

He specifies, satisfies the remaining two specifications - the good part is that no more things needs to be pulled into existence, as the necessary capabilities are already in place.

Hank-the-HTTP-handler is satisfied.

app.MapPost("/tours/{tourId}/activate", async (
    [FromRoute] string tourId,
    [FromServices] PublishedToursRepository publishedTourRepository
) =>
{
    var publishedTour = await publishedTourRepository.GetPublishedTourById(tourId);
    if (publishedTour is null)
    {
        return Results.NotFound();
    }
    if (tourId.IsActive)
    {
        return Results.Conflict();
    }

    await publishedTourRepository.Save(publishedTour with { IsActive = true });

    return Results.Ok();
}
);

The capability is not yet complete, as the actual implementation of PublishedToursRepository is still missing.

The good part is that the contract between a potential provider was already specified using consumer-expected, need-driven approach.

Let's make a pause and ask ourselves, dear Reader: why are we saving anything to durable storage at all?

Probably to read it later, right?

Hank-the-designer ponders about it and decided to specify another case: given an existing published tour that was just activated, when getting it, then returns published tour with active status.

He wants to use testcontainers to have a real Postgres database for this specification, as he (in the designer's role) wants to pull a new capability provider into existence: PostgresPublishedToursRepository.

Let's imagine he did so and tests use real database now, and he's ready to specify the behavior.

[Fact]
public async Task given_existing_published_tour_that_was_just_activated_when_getting_it_then_returns_published_tour_with_active_status()
{
    // given
    var EXISTING_TOUR_ID = "tour-123";

    var client = PublishedToursWebService.CreateHttpClient();

    var tourToPublish = new StringContent($@"{{
        ""Id"": ""{EXISTING_TOUR_ID}"",
        ""IsActive"": false,
        ""PublishedAt"": ""2023-01-01T00:00:00Z"",
        ""Title"": ""Sample Tour"",
        ""Description"": ""This is a sample tour."",
        ""TourPlannerId"": ""planner-001""
    }}";

    _ = await client.PostAsync($"/tours/", tourToPublish, Encoding.UTF8, "application/json"));
    _ = await client.PostAsync($"/tours/{EXISTING_TOUR_ID}/activate", null);

    // when
    var response = await client.GetAsync($"/tours/{EXISTING_TOUR_ID}");
    var responseBody = await response.Content.ReadAsStringAsync();

    // then
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    Assert.Contains(@"""IsActive"":true", responseBody);
}

A capability for publishing tour was already in place, so Hank used it to set up the scenario.

The entire spec, after running, failed, as expected.

The real demand is there - time to pull into the existence a Postgres provider for the capability for loading published tours awaiting activation and saving them after activation.

This means that now it's specification satisfaction time again.

For a while, Hank started playing a role of a capability provider - PublishedToursRepository - using Postgres and having the necessary knowledge to only satisfy the current needs, specified by the contract which got designed with consumer feedback.

public class PostgresPublishedToursRepository : PublishedToursRepository
{
    private readonly string _connectionString;

    public PostgresPublishedToursRepository(string connectionString)
    {
        _connectionString = connectionString;
    }

    public async Task<PublishedTourToActivate?> GetPublishedTourById(string tourId)
    {
        await using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        var command = new NpgsqlCommand("SELECT Id, IsActive FROM PublishedTours WHERE Id = @Id", connection);
        command.Parameters.AddWithValue("Id", tourId);

        await using var reader = await command.ExecuteReaderAsync();
        if (await reader.ReadAsync())
        {
            return new PublishedTourToActivate(
                reader.GetString(0),
                reader.GetBoolean(1)
            );
        }

        return null;
    }

    public async Task Save(PublishedTourToActivate publishedTour)
    {
        await using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        var command = new NpgsqlCommand("UPDATE PublishedTours SET IsActive = @IsActive WHERE Id = @Id", connection);
        command.Parameters.AddWithValue("Id", publishedTour.Id);
        command.Parameters.AddWithValue("IsActive", publishedTour.IsActive);

        await command.ExecuteNonQueryAsync();
    }
}

Hank-the-postgres-capability-provider did only the required work to adhere to the contract - nothing more, nothing less.

"There is no waste here", he thought.

Hank-the-designer added this newly pulled capability provider to a dependency register in Program.cs.

All specs were executed, which passed successfully.

He started simplifying the design and structure, organizing types, files here and there, running specs after each change to ensure everything is still green.

Specify, satisfy...Deploy!

Hank-the-designer decided that the design is good enough for now.

There is no need to pull more concepts into existence at this point, like application services or other contructs.

It will be unnecessary work at this moment - "waste".

He decided to push the changes to main branch, which then got deployed to staging environment.

Tom was able to use the real implementation of published tours activation right away, testing it end-to-end with the UI.

Almost no changes were required, as the contract remained unchanged.

Of course, Hank recalled that there's a need for geotracking session creation when a tour is activated.

He decided to specify it next, following the same workflow of consumer-expected, need-driven behavior development.

Satisfy, satisfy, maybe simplify, and then deploy!

Consumers, needs, capabilities, providers

"What the heck just happened?", you might be wondering, dear Reader.

One might say that the entire tale was very boilerplate-y, especially the parts about specifying, satisfying, simplifying.

During that tale and various checkpoints, we were able to see how Hank switched roles multiple times:

  • from a capability consumer (HTTP handler) specifying needs for loading and saving published tours
  • to a capability provider (PublishedToursRepository) satisfying those needs using Postgres
  • to a system designer, pulling into existence the necessary capabilities to satisfy the system needs
  • back to a capability consumer (HTTP handler) using those capabilities to provide the expected behavior over HTTP
  • and so on...

Typically, those role switches are invisible, happening in the mind of the engineer.

Which actually, might be pretty dangerous as we might bake wrong assumptions about the needs, expected behavior, capabilities, and so on.

That's what we explored in the previous article, dear Reader.

It might not be explicitly visible, but along the way, we were able to see Lean principles in action.

In the "Mock Roles, Not Objects" paper, the authors mention:

Conclusion 🔍

A core principle of Lean Development is that value should be pulled into existence from demand, rather than pushed from implementation: “The effect of 'pull' is that production is not based on forecast; commitment is delayed until demand is present to indicate what the customer really wants.” [16].

Waste-reduction at its essence, isn't it, dear Reader?

They continue by stating:

Conclusion 🔍

TDD with Mock Objects guides interface design by the services that an object requires, not just those it provides. This process results in a system of narrow interfaces each of which defines a role in an interaction between objects, rather than wide interfaces that describe all the features provided by a class. We call this approach Need-Driven Development.

Which is exactly what we were able to observe in action - focusing on the required capabilities, driven by the consumer-expected needs.

It does not matter if the consumer was HTTP handler, Tom or an executable specification (test).

Providers treated those needs seriously, satisfying them with minimal effort, avoiding waste.

Everything was driven by the rapid feedback loop - either through tests or end-to-end usage.

By shipping "a walking skeleton", Hank enabled early learning, reducing the risk of building something nobody needs, as Tom was able to start using the feature right away, providing that valuable feedback.

Tests were the first consumers of the contracts, behaviors and results - they were the foundation of fast feedback loop.

They were used as a design tool.

Tests are multipurpose, hence they might feel a bit ambiguous.

As authors of "Mock Roles, Not Objects" paper put it:

Conclusion 🔍

Using TDD has many benefits but the most relevant is that it directs the programmer to think about the design of code from its intended use, rather than from its implementation.

"But Hank did not follow any modern application architecture like Clean Architecture!", some might say.

Where is the problem? Hank focused on delivering value and he mindfully decided not to pull unnecessary concepts into existence while going through simplifying stage.

It's not the implementation that is the real treasure, but specifications, especially in the AI times.

What would be the required effort for creating TourActivationService or ActivatePublishedTourCommandHandler?

Executable specifications are there, it will be pretty safe to pull them into existence, if there will be a need - for instance when a reader (another consumer) will find it difficult to understand, because there will be HTTP-related noise.

What is truly important and huge is that through entire tale a miracle of "interface discovery" happened - Hank-the-designer used mocking (a process) to purposefully establish boundaries and specify contracts that were required from consumer perspectives.

One might aim at ending up with a Ports & Adapters architecture or Clean Architecture, but those are the final state.

We saw in the previous article the Inside-Out approach for development - quite speculative - which does not leave space for discovery, learning and adaptation.

"Mock Roles, Not Objects" also captures the difference between provider-driven and consumer-driven approaches:

Conclusion 🔍

This changes design from a process of invention, where the developer thinks hard about what a unit of code should do and then implements it, to a process of discovery, where the developer adds small increments of functionality and then extracts structure from the working code.

Throughtout the entire tale, we experienced Outside-In approach for development, driven by consumer-expected needs.

It was a designer's decision when it is a right moment to pull new concepts into existence.

When and where the boundaries should be established.

Why so slow?

You might be wondering, dear Reader, why the entire process seemed so slow and verbose.

"All those microsteps, small refactoring activities, tiny ifs - do I really need to do that?", you might be asking, dear Reader.

The answer is: it depends.

It depends on how confident you feel about making larger steps.

If one struggles with making tests green - take smaller steps.

Edit a variable name, run tests.

Extract method, run tests.

Change place of an if, run tests.

Feeling more confident?

Add more logic - order interactions in a new sequence, add conditionals - run tests.

Distribute responsibilities, move things around - run tests.

Suddenly you feel you lost control? Make smaller steps again.

Watch for absolutisms - the beauty of being a human is that we can adapt, learn and change our minds.

Slowly roll out requirements, based on the needs and provide those capabilities, simplifying along the way.

And it does not really matter whether we are providing capabilities for human beings, other systems or tiny objects - outside-in perspective remains the same.

Who is the consumer?

What are the needs?

What capabilities are required to satisfy those needs?

And so on.

Happy designing, dear Reader!