Published on

The ambiguity of easy: what does it mean?

Authors

"I want it to be easy"

Imagine that three people - Alice, the Architect, Peter, the Providing Developer, and Chris, the Consuming Developer - are discussing a new feature for their software application.

Chris, the Consuming Developer wants a new capability to be provided so that his team can complete their work.

Both the architect and the providing developer represent the another side of the relationship, trying to understand the needs.

Discussion
Chris, the Consuming Developer: Hey! Finally, we have the new feature we've been waiting for.
Peter, the Providing Developer: Hi! Yes, I hope we will be able to respond to your call quite efficiently.
Alice, the Architect: Let's make sure we understand the requirements clearly before we proceed.
Chris, the Consuming Developer: So, I need an HTTP endpoint that enables ordering an equipment for each worker identifier or worker reference provided.
Peter, the Providing Developer: Why do you need both identifiers and references?
Chris, the Consuming Developer: Well, sometimes we have the worker's ID, and other times we only have a reference. We need the endpoint to handle both cases.
Alice, the Architect: If we are going to provide you such capability, we need to have a uniform contract so that others might use it too.
Chris, the Consuming Developer: I just want it to be easy to use. That's it. I don't see what is the problem.
Peter, the Providing Developer: Hmm, I see your point, but the implementation wouldn't be that easy for us - we don't use references at all. I would need to introduce logic to handle that.
Alice, the Architect: It is not the first time when we've encountered such a situation - dual way of identifying resources can lead to inconsistencies which later on makes it harder to maintain the system.
Peter, the Providing Developer: I think I could ask another service that contains the master data for workers - maybe it could exchange worker identifiers? Then would be super easy for me to provide the capability you need, honouring both identifiers - because I would delegate everything to the upstream service and we already use it in another place of the system.
Alice, the Architect: I don't like it - it does not make it easy from the system perspective - I don't want Peter's service to be responsible for exchanging identifiers. I think we need to be always based on a worker identifier, and forget about references. This will make it easier for everyone.
Chris, the Consuming Developer: "Easier for everyone"? You must be kidding, right? I just told you I can't guarantee that we will always have the worker's ID.
Alice, the Architect: If you can't, then you need to exchange identities. Peter's service shouldn't do it, in my opinion. From the system perspective, this service shouldn't even know about "worker reference".

As they were doing circles without any outcomes, someone got an idea to start visualizing various options.

They went with Event Storming notation and conjured a quick process level diagram.

Process level Event Storming for a tiny worker assignment process

Neither of them liked the solution of another, because it either put too much responsibilities on one side or made the system harder to maintain.

They tried to find consensus, but it was tough to be achieved, knowing the constraints they had.

Being caught in "analysis paralysis" isn't the best state, Alice, the Architect noticed that and decided to write an ADR in which they specify all options.

Then the architect decided to go with the option putting the responsibility for identifiers exchange on the Chris, the Consuming Developer side.

Of course, it didn't make him happy, but at least they moved on, remembering that "Bad decision isn't bad, if you know it's bad".

"At least it's easy"

As it was decided, Chris, the Consuming Developer is going to handle the exchange of identifiers.

In fact, he is providing yet another capability - assigning workers to jobs - which is consumed by the web application.

So Chris, the Consuming Developer also plays the role of a provider.

Chris, the Consuming Developer wanted to have easy interface provided by Peter's service, but it turned out that it wasn't that easy (pun intended) to convince people.

So then he started specifying the capability that his service is going to provide, knowing that along with consuming equipment ordering capability, it also needs consume identifiers exchanging.

[Fact]
public async Task given_existing_job_when_successfully_assigning_worker_by_id_then_confirms_successful_assignment()
{
    var jobId = "job-123";
    var workerId = "b692976a-b25c-450c-a5e1-c190cd5c09c4";

    var response = await _client.PutAsync($"/jobs/{jobId}/assign/{workerId}", null);

    response.StatusCode.Should().Be(HttpStatusCode.OK);
}

The test failed ❌ because there was no implementation.

This is what a consumer of his service would do to assign a worker to a job, and what expects to get back as confirmation.

Implementation is also straightforward:

app.MapPut("/jobs/{jobId}/assign/{workerIdOrWorkerReference}", // 👈 new!
    (string jobId, string workerIdOrWorkerReference) =>
    {
        return Results.Ok();
    }
);

And this made the test passed ✅.

Chris, the Consuming Developer then specifies the next consequence of successful assignment - saving the assignment.

[Fact]
public async Task given_existing_job_when_successfully_assigning_worker_by_id_then_assignment_is_saved()
{
    var jobId = "job-123";
    var workerId = "b692976a-b25c-450c-a5e1-c190cd5c09c4";

    await _client.PutAsync($"/jobs/{jobId}/assign/{workerId}", null);

    var assignments = 
        _factory.Services.GetRequiredService<IGetAssignmentById>(); // 👈 in runtime this resolves to `PostgresAssignmentRepository`
    var assignment = await assignments.GetById(jobId);

    assignment.Should().NotBeNull();
    assignment!.WorkerId.Should().Be(workerId);
}

The test failed ❌ because there was no saving yet.

Thankfully, the solution already uses testcontainers so it is super easy to run these tests in an isolated environment.

No mocking required, so less amount of code is needed to actually verify the real saving to database.

The downside is that this simple test requires additional time to start the Postgres container.

Then, Chris, the Consuming Developer implements the remaining logic:

app.MapPut("/jobs/{jobId}/assign/{workerIdOrWorkerReference}", 
async (
    string jobId,
    string workerIdOrWorkerReference,
    ISaveAssignment assignments // 👈 new!
) =>
{
    await assignments.Save(new Assignment(jobId, workerIdOrWorkerReference)); // 👈 new!

    return Results.Ok();
});

And now, both tests passed ✅.

Chris isn't an advocate for such narrow interfaces, they seem too fine-grained, too unit-esque.

It isn't something that he decided on - if we could, he would name it as IAssignmentsRepository, at least the pattern is well-known in the industry.

But he asked himself - "are there any benefits of using such narrow interfaces?", but a message on Teams broke this insightful moment.

The consumer of the capability provided by Chris's service can now confidently assign workers to jobs and have those assignments saved correctly, along with expected confirmation response.

But even those tests are small, working on real database interactions provides a delayed feedback, as tests startup takes longer.

"At least it's easy to write those tests, as I don't need unnecessary mocking.", thought Chris, the Consuming Developer.

"Let's not complicate it"

Chris, the Consuming Developer to progress with provided capability, it's time to order equipment using Peter, the Providing Developer.

"The easiest would be to verify ordering on the HTTP level", told Chris, the Consuming Developer himself.

So he did it:

[Fact]
public async Task given_existing_job_when_successfully_assigning_worker_by_id_then_equipment_is_ordered()
{
    var jobId = "job-123";
    var workerId = "b692976a-b25c-450c-a5e1-c190cd5c09c4";

    _wiremock
        .Given(Request.Create()
            .WithPath("/equipment-orders")
            .UsingPost())
        .RespondWith(Response.Create()
            .WithStatusCode(HttpStatusCode.OK));

    await _client.PutAsync($"/jobs/{jobId}/assign/{workerId}", null);

    _wiremock.Should().HaveReceived(1).Calls()
        .WithPath("/equipment-orders")
        .UsingPost()
        .WithBodyAsJson(b => b.WorkerId == workerId);
}

The test failed ❌ because there was no implementation of equipment ordering.

Observing unsatisfied behavior, the developer stated implementing it:

app.MapPut("/jobs/{jobId}/assign/{workerIdOrWorkerReference}", async (
    string jobId,
    string workerIdOrWorkerReference,
    ISaveAssignment assignments,
    HttpClient equipmentOrderingClient // 👈 new!
) =>
{
    await assignments.Save(new Assignment(jobId, workerIdOrWorkerReference));

    await equipmentOrderingClient.PostAsJsonAsync("/equipment-orders", new // 👈 new!
    {
        WorkerId = workerIdOrWorkerReference
    });

    return Results.Ok();
});

And this made all tests pass ✅.

He observed even longer time needed to get feedback.

Here came the final boss - exchanging worker identifiers. Thankfully, Alice, the Architect provided the necessary contract for doing the exchange.

It was a simple HTTP GET interaction to https://api.workers.bigcorp.io/workers/{workerIdOrWorkerReference}, which gave entire worker object back.

Specifying this behavior for scenario with worker reference was easy, as WireMock.NET was already used:

[Fact]
public async Task given_existing_job_when_assigning_worker_by_reference_then_worker_should_be_assigned_to_job()
{
    var jobId = "job-123";
    var workerReference = "EMP-ABC";
    var resolvedWorkerId = "b692976a-b25c-450c-a5e1-c190cd5c09c4";

    _wiremock
        .Given(Request.Create()
            .WithPath($"/workers/{workerReference}")
            .UsingGet())
        .RespondWith(Response.Create()
            .WithStatusCode(HttpStatusCode.OK)
            .WithBodyAsJson(new { Id = resolvedWorkerId, Reference = workerReference }));

    _wiremock
        .Given(Request.Create()
            .WithPath("/equipment-orders")
            .UsingPost())
        .RespondWith(Response.Create()
            .WithStatusCode(HttpStatusCode.OK));

    var response = await _client.PutAsync($"/jobs/{jobId}/assign/{workerReference}", null);

    response.StatusCode.Should().Be(HttpStatusCode.OK);
    _wiremock.Should().HaveReceived(1).Calls()
        .WithPath("/equipment-orders")
        .UsingPost()
        .WithBodyAsJson(b => b.WorkerId == resolvedWorkerId);
}

The test failed ❌ because there was no exchange logic provided.

So Chris, the Consuming Developer started satisfying it.

app.MapPut("/jobs/{jobId}/assign/{workerIdOrWorkerReference}", async (
    string jobId,
    string workerIdOrWorkerReference,
    ISaveAssignment assignments,
    HttpClient equipmentOrderingClient,
    HttpClient workersClient // 👈 new!
) =>
{
    var workerId = workerIdOrWorkerReference;

    // worker ids are GUIDs
    if (!Guid.TryParse(workerIdOrWorkerReference, out _))
    {
        var worker = await workersClient
            .GetFromJsonAsync<WorkerResponse>($"/workers/{workerIdOrWorkerReference}");

        workerId = worker!.Id;
    }

    await assignments.Save(new Assignment(jobId, workerId));

    await equipmentOrderingClient.PostAsJsonAsync("/equipment-orders", new // 👈 new!
    {
        WorkerId = workerId
    });

    return Results.Ok();
});

And this made all tests pass ✅ again.

Chris, the Consuming Developer was pretty proud, as it didn't take much time and was pretty easy to implement.

He didn't even need to add too many new classes, and what is pretty important - he avoided mocking, and he heard that mocking leads to testing implementIt meant to be easyation details, which then leads to brittle tests.

For a while he thought of introducing mocks, at least for a small boundary between HTTP handler and HTTP clients, but then said to himself - "let's not complicate it".

Then, he started working on error scenarios.

What does it mean "easy"?

"Isn't a joke? TDD in the LLM world?", one might ask.

Bear with me, dear Reader.

We saw three roles collaborating to satisfy the needs.

The needs - of whom?

In the first part of the tale, we observed vivid discussion of three parties - each having their own understanding of "easy".

The consumer wanted to have easy way of using the capability exposed through HTTP interface, by not needing to care about identifiers.

The architect wanted to have easy way of maintaining and evolving the system, by clear boundaries and responsibilities.

The provider wanted to have easy way of providing the capability, by delegating identifiers exchange to another service and just doing the HTTP interaction.

It is not difficult to see that in their eyes, the quality attribute "easiness" meant three different things, because the roles were physically separated.

But what about the second part of the tale?

Wasn't it really different from the discussion between three people?

It might actually turn out to be a good example of the invisible role-switching problem.

A single person, Chris, unconsciously and dynamically swapped the perspective, multiple times.

One time he played the role of a consumer for "his" provided capability, next time he thought from the providers perspective.

When he started doubting whether more time needed to get feedback is worth it, provided that the system will grow in complexity - then we could say that Chris looked at "things" from a system's designer perspective, taking into account quality attributes, like maintainability or testability.

As those role switching was done mostly in his head, it remained invisible to others.

Even to himself, especially when "being in the process".

Each role having its own perspective

Roles (consumer, provider, designer) does not disappear, they are assigned or relocated differently.

A consumer looks from outside perspective.

A provider looks from inside perspective.

A designer looks from a system-level perspective - observing both outside and inside parts.

Sometimes to different people, sometimes to the same person at different moments.

Also, the tension between various needs remains similar, especially when one tries to take each and every perspective into account.

And we might arrive at the conclusion - "there is no single easy".

What does it mean "easy"?

Shocking mocking?

When dealing with implementation work, Chris used consumer-driven approach for driving the implementation of capability that was expected to be provided by the service he owned.

He was able to specify the contracts (interfaces, along with required and provided information), from the HTTP interface consumer perspective.

Additionally to that, he went with "the easiest implementation" possible (at least in his mind) - designing against HTTP boundary and against real database.

I know, I know - it is a bit exaggerated to distinguish between a provider and a designer perspectives, mainly because while implementing, one is also designing, on some level.

But the designer might have more system-level approach for solving problems - was WireMock a right choice, from maintainability perspective?

What about test containers, and testing a typical state-changing process?

If you recall, Chris for a while switched to "a designer" perspective, asking about mocking.

How would mocking change the system's behavior and maintainability?

Anti-mocking movement would tell you it's straightforward - mocks break encapsulation.

Let's stop for a while - what does "mocking" mean? Are we sure we have the same "thing" in our heads?

Ok, back to the main topic.

It might be arguable to even introduce "designer's perspective", but imagine a "jira ticket jockey" who just want to close the ticket, without caring about the system - even if they are implementing, are they really designing consciously?

And what about "easy" - what does it really mean in this context?

Effortless now, effortful later?

I hope you know dear Reader that I like definitions, because they contribute to building understanding and clarity, so here we go:

easy - a definition

Requiring or exhibiting little effort or endeavor

In Simple ain't easy, we tried to deepen our understanding when it comes those two terms.

Building something without adding more code might seem simple, but will it be after, let's say, one year?

Or maybe it is just "easy", meaning - requiring little effort? If it is, who's stating that - a consumer? A provider? A designer?

The situational awareness might be really helpful when it comes to understanding the context and perspective from which "easy" is being evaluated.

As we saw in 3S, specifications through tests might be useful mental tool for "putting on" consumer's glasses.

Which does not mean we magically will not get our hands (or heads?) dirty by looking inside, when it comes to designing internal quality.

Boundaries are not only valuable, but necessary to control complexity - technical, cognitive and organizational.

Establishing them doesn't only happen at one level - boundaries are fractal in their nature, so pick a level and you will find them there too.

And without boundaries, we will lose ("the game").

Sooner or later.

This leads us to the final conclusion - "easy" is relative, context-dependent.

In DDD, the language plays a crucial role when it comes to discovery and design - because in the hands (of head) of mindful practitioners, it enables noticing the subtle differences between meaning.

It's all about semantics, one could say.

So even though "easy" means "little effort", it varies when it comes to the subject of it.

"Easy" bounded by the consumer context means something different than "easy" bounded by the designer context.

It's simple, but not easy to understand "easy".

So next time, dear Reader, when someone (including yourself) drops "I wanted it to be easy", be mindful and ask yourself:

Question 🤔

Who is asking?

What does it mean "easy" in their perspective?