Published on

The invisible role-switching problem and Need-Driven Design

Authors

Application logic - implementer's perspective

Imagine that there's a developer named Hank, working in a local, guided tourism business area and his current assignment is to implement a simple workflow for activating published tour.

One important step in that workflow is that whenever a tour is successfully activated, a new geotracking session needs to be created.

Hank learned that such new geotracking session will be created in inactive state, but in the future it might change, meaning that we might create a new session that should be immediately active.

So Hank starts his implementation by adding a simple repository for creating new geotracking sessions:

public class GeotrackingSessionRepository
{
    public async Task<GeotrackingSession> CreateNewSession(Guid id, bool isActive) // 👈 Hank prepared this method to work with future, immediately active sessions
    {
        var session = new GeotrackingSession
        {
            Id = id,
            IsActive = isActive,
            CreatedAt = DateTime.UtcNow
        };

        _geotrackingSessionsDbContext.Add(session);
        await _geotrackingSessionsDbContext.SaveChangesAsync();

        return session;
    }
}

Then Hank used his IDE to pull an interface out of that repository:

public interface IGeotrackingSessionRepository
{
    Task<GeotrackingSession> CreateNewSession(Guid id, bool isActive);
}

Almost there!

Now Hank can implement the tour activation workflow, using that repository:

public class TourActivationService(
    IGeotrackingSessionRepository geotrackingSessionRepository,
    ITourRepository tourRepository
)
{
    public async Task ActivateTour(Guid tourId)
    {
        var publishedTour = await tourRepository.GetPublishedTourById(tourId);

        if(publishedTour == null)
        {
            throw new InvalidOperationException("Tour not found.");
        }

        if(publishedTour?.IsActive)
        {
            throw new InvalidOperationException("Tour is already active.");
        }
        publishedTour.IsActive = true; // 👈 Hank implemented this step accordingly to business logic
        publishedTour.ActivatedAt = DateTime.UtcNow;
        await tourRepository.Save(publishedTour);
        var _ = await geotrackingSessionRepository.CreateNewSession(Guid.NewGuid(), isActive: false); // 👈 Hank uses the repository to create a new, inactive geotracking session
        // we discard the created session, as it is not needed in this context
    }
}

So far, so good.

Tours repository was omitted, but it's easy to imagine that it has methods for retrieving published tours and saving them back to the database.

Nice that Hank thought about future requirements and prepared the repository method to handle both active and inactive sessions.

Yes, it was a bit speculative, but hey, better be prepared, right?

Hank is happy with his implementation, and he even wrote tests, verifying that the application logic works correctly!

[Fact]
public async Task should_throw_an_exception_when_no_tour_found()
{
    // Arrange
    var NON_EXISTING_TOUR_ID = Guid.NewGuid();
    var tourActivationService = new TourActivationService(
        geotrackingSessionRepository: new InMemoryGeotrackingSessionRepository(),
        tourRepository: new InMemoryTourRepository()
    );

    // Act & Assert
    var exception =
        await Assert.ThrowsAsync<InvalidOperationException>(() => tourActivationService.ActivateTour(NON_EXISTING_TOUR_ID));
    
    Assert.Equal("Tour not found.", exception.Message);
}

[Fact]
public async Task should_throw_an_exception_when_activating_already_active_published_tour()
{
    // Arrange
    var EXISTING_ACTIVE_TOUR_ID = Guid.NewGuid();
    var inMemoryTourRepository = new InMemoryTourRepository();
    inMemoryTourRepository.AddPublishedTour(new PublishedTour
    {
        Id = EXISTING_ACTIVE_TOUR_ID,
        IsActive = true
    });

    var tourActivationService = new TourActivationService(
        geotrackingSessionRepository: new InMemoryGeotrackingSessionRepository(),
        tourRepository: inMemoryTourRepository
    );

    // Act & Assert
    var exception =
        await Assert.ThrowsAsync<InvalidOperationException>(() => tourActivationService.ActivateTour(EXISTING_ACTIVE_TOUR_ID));
    
    Assert.Equal("Tour is already active.", exception.Message);
}

Application service looks quite straightforward, I believe that you, dear Reader, might have seen similar code before.

Documentation - writer's perspective

An interruption came and Hank was asked to write documentation for running database migrations and seeding initial data for the application.

Now, let's imagine we see Hank in action, when he puts a writer's hat on.

He used that workflow many, many times so he's a right person to put that knowledge down.

He opens a new file and starts writing:

Run `dbmig -a -s` in the root. Don't forget to check the LSN if you're on PG13+.

The migration script uses a custom SQL generator that leverages our internal DbMigrationHelper class.

It constructs the schema using a fluent API and applies transactional boundaries for each operation.

After setup, everything should work as expected. If you encounter any issues, ask a team member.

Easy peasy, lemon squeezy.

15 min and the documentation is ready.

It looks quite straightforward, at the time of writing. I believe that you, dear Reader, might have seen similar documentation before.

Web service HTTP interaction - provider's perspective

Finally, back to implementing feature related to published tours activation.

Let's imagine that Hank needs to implement a web service endpoint that will be called whenever a published tour needs to be activated.

He immediately puts on his provider's hat and starts implementing the HTTP endpoint using ASP.NET Core Minimal API.

Also, Hank remembers what he implemented, before he got interrupted by writing documentation, when it comes to application logic.

But you know as it is dear Reader, to be 100% sure that his memory didn't fool him, he checks the logic again.

Now, implementation time!

app.MapPost("/tours/{tourId}/activate", async
(
    [FromRoute] Guid tourId,
    [FromServices] TourActivationService tourActivationService
    ) =>
{   
    try 
    {
        await tourActivationService.ActivateTour(tourId);
        return Results.Ok();
    }
    catch (InvalidOperationException ex) when (ex.Message.Contains("Tour not found.")) // 👈 Hank handled a scenario when a tour was not found
    {
        return Results.NotFound(new { error = ex.Message });
    }
    catch (InvalidOperationException ex) when (ex.Message.Contains("Tour is already active.")) // 👈 Hank handled a scenario when a tour was already active
    {
        return Results.Conflict(new { error = ex.Message });
    }
    catch (Exception ex)
    {
        return Results.BadRequest(new { error = ex.Message });
    }
});

Hank ran the application couple of times and tested the endpoint using Postman, just to be sure that everything works as expected.

And of course, because he is quite good developer, everything worked as expected.

Nice job, Hank!

Needs?

"Ok, but what is the point of those stories?" - some might ask while being doubtful.

In every scenario, we've observed Hank doing some work - "writing code" or writing documentation.

Documentation part might feel a bit odd, but bear with me, dear Reader.

In both "coding" scenarios, Hank worked on the same feature, but on two different levels: on HTTP interaction handling level and on application logic level.

He started from low-level details, providing an implementation for geotracking session logic - he anticipated that maybe in the future, we might need to create active geotracking sessions right away.

Please note dear Reader that when I say "logic" I have a very specific meaning in mind - it is the knowledge about how things should work - what is the sequence of steps, what are the conditions/rules, what are return values, etc.

I should probably say "an implementation of the knowledge about geotracking session creation", but it wouldn't sound quite natural, hence I abstracted away the details and covered them under the term "logic".

He moved "up" and went to "application logic" level - implementing the workflow and verifying if it works by writing two tests against that logic.

Finally, he moved "up" again and implemented the HTTP endpoint that will be called by web service clients.

"So what? Everything seems fine to me." - many might comment.

But here comes the tricky part - if you, dear Reader, were asked about the role that Hank played in each of those scenarios, what would your answer be?

Well, at least according to section titles, he was a developer, an implementer, right?

So he implemented repository logic, which then was consumed by application logic, which he implemented, which then was consumed by HTTP endpoint logic, which he also implemented, which eventually will be consumed by web service clients.

A beautiful dance between implementing and consuming.

So Hank played two different roles - an implementer and a consumer.

Each implementation resulted in providing a certain capability: either creating a new geotracking session, activating a published tour, or handling an HTTP request.

Then, in a way, each implementation also used a certain provided capability: either creating a new geotracking session or activating a published tour (or, in case of HTTP endpoint, interacting with web service to activate published tour via HTTP protocol).

So Hank switched roles multiple times - from logic implementing provider to capability consumer, and back.

God-like architect

Also, an avid Reader might notice that on various levels the decisions made were influenced and impacted by the omniscient knowledge of the designing person.

Implicitly.

Let's play a role of an application service, just for a while.

Are you with me, dear Reader?

Ok, let's become an application service - do we need to have the knowledge about IsActive property of a published tour, in this particular scenario?

Well, we could say that we do, because we need to check if the tour is already active, before activating it.

But wait a second - who decided that we need to have such knowledge in the first place?

The needs of ours are quite clear - we need to activate a published tour and if it succeeds, we need to create a new geotracking session.

This is the knowledge we are supposed to have, right?

It's as if we didn't trust PublishedTour object to provide us with the necessary behavior - we had to know about IsActive property and check it ourselves.

Shouldn't we tell it to activate itself, and let it handle the details internally?

Additionally, what are our (as application service, remember) needs when it comes to geotracking session creation?

IGeotrackingSessionRepository provides a contract requiring us to have the knowledge about isActive parameter.

In this particular scenario, we know that we need to create an inactive session, which makes using that interface's method with isActive parameter a bit ambiguous (from an application service perspective).

I know, I know - it is sooooooooo low-level that it might seem irrelevant, especially in cheap code production times as we have nowadays, thanks to LLMs.

But behind those nitty gritty details lies a very important problem - the designer is influencing the design by representing too much knowledge on levels which shouldn't have that knowledge at all, unconsciously coupling those levels together.

In this tiny example, it might seem harmless, but training one's sight to spot such issues is a very important skill for any software designer.

According to the knowledge gravity problem, knowledge attracts more knowledge.

Provider-driven design?

Let's jump to HTTP request handling level for a while.

The knowledge represented there also might look just fine - but when an avid Reader inspects it closely, they might notice that those message-checking conditions are quite smelly.

Hank had to know about those error messages in order to handle them properly, on different level than application logic level.

So then he a provided capability for doing so - by using exception messages as a way to communicate error conditions.

But now it's the consuming party that needs to have the knowledge about distinguishing those differrent error cases.

And unintended coupling was summoned.

Seems like the pattern was as follows: providing, consuming, providing, consuming, etc. - Hank firstly provided capabilities by implementing logic, then consumed whatever was produced by himself, but on a different level, and "with different hat".

Which brings us to the main point - it was a provider-driven design, shaping the knowledge distribution, creating possible unintended coupling between different levels of the system.

The invisible role-switching problem

So, dear Reader, what is the hardest part of software designing?

Many say it's naming and cache invalidation.

You might recall that naming is indeed impactful, because it frames our thinking.

But as we are developing and designing classes, objects collaboration, services interaction, architectural elements, we are switching roles constantly.

Sometimes it happens so subtly that we don't even notice it.

We switch from being a capability provider to being a capability consumer, and back.

Often completly forgetting the needs of the consumer, and focusing solely on providing capabilities.

Obsessing ourselves with low-level implementation details, forgetting that we are designing something for a purpose (you might be interested in Composing a PIE).

As you saw, dear Reader, Hank was jumping between roles, changing hats, without even realizing it.

It was transparent, invisible, under the radar.

A true designing requires awareness, mindfulness and discipline.

It requires a real role-playing skill.

Deliberate acting as a capability consumer, requesting a certain behavior, based on the needs, then switching to a capability provider role, implementing the requested behavior, and so on.

And this is exactly where TDD can help a lot - by forcing us to think in terms of needs first, then providing the necessary capabilities.

As we discussed in TDD: specify, satisfy, simplify, it is about specifying consumer-expected behavior, using tests.

Tests are vital, as they ought to provide rapid feedback.

Documentation and the invisible role-switching problem?

You might still wonder, dear Reader, about documentation writing scenario - how does it relate to the invisible role-switching problem?

An interaction between two roles - a writer and a reader - also expresses similar relationship and dynamics, as we observed in "coding" scenarios.

When writing documentation, Hank had to think about the needs of the reader.

What would the reader need to know in order to successfully run database migrations and seed initial data?

What information is essential, what can be omitted, from the reader's perspective?

What details might be helpful in two or three months' time?

Then, after identifying those needs, Hank should have provided the necessary capabilities by writing clear and concise instructions.

If one looks from a right level and angle, it also resembles provider-consumer relationship, expressing invisible role-switching problem.

But it might not be that easy to verify, as with the code, because documentation correctness is often subjective and context-dependent.

So it is consumer-expected capability specification, although feedback look is not as rapid and deterministic as with tests.

This also highlights a very important point - a consumer, a provider and their relationship appears on various levels, in different contexts, in different forms.

Those roles manifest fractally.

A consumer might be another object.

A consumer might be another module.

A consumer might be another web service.

A consumer might be another human being.

So when we play a role of a consumer, we should employ "wishful amnesia", forgetting about "hows" (at least temporarily), focusing on "whats".

Specifying the contract - what we are required to provide, what we expect to get.

Specifying expected behavior, according to the given contract.

This deliberate "forgetting" is very, very important, and I feel we don't practice it enough.

Question 🤔

Who is going to consume what I am about to provide? What are their needs?

Constraints, opportunities and discipline

The invisible role-switching problem is quite subtle and tricky.

But actually we might exploit this phenomenon to our advantage by consciously playing respective roles.

Start from consumer's perspective - identify the needs, specify the expected behavior, then switch to provider's perspective - implement the requested behavior.

In the code we have an advantage by using tests as a way to specify consumer-expected behavior, getting rapid feedback.

Taiichi Ohno, the father of Toyota Production System, pointed out that:

Conclusion 🔍

Overproduction is the biggest waste.

Knowing that, as designers, we should focus on needs of the consumers, avoiding overproduction.

By knowing the purpose, the needs, we switch hats to become a capability provider and provide exactly what is needed, no more, no less, for a particular use case, in a given context.

As a capability provider, we should empathize with the consumer - whether it is another object, a web service, or a human being.

Consumer's needs matters and we should pay close attention to them, as they influence our design decisions.

Conclusion 🔍

Each constraint might be an opportunity, each opportunity might be a constraint.

Constraining ourselves and framing our thinking to reason either as a consumer or as a provider, liberates us from unnecessary complexity, overengineering and overproduction.

It gives us the right perspective, the right mindset.

But unfortunately, things are even harder than that.

Next to a (capability) consumer, a (capability) provider - we also need to play the role of a designer/an architect.

We need to constantly observe the big picture, the overall knowledge distribution, the boundaries between different levels of the system, how the knowledge is represented, coupled and if it is cohesive enough.

Suddenly, it becomes crucial to work on self-awareness, discipline, and knowing what role we are playing at a given moment.

Remembering, it is just a role and we are capable of switching it, whenever needed.

So next time, dear Reader, when you find yourself designing architectural elements, services, documentation, objects collaboration, pause for a moment and ask yourself a question:

Question 🤔

Who am I, right now?

Attention!

If you want to learn more about practical way for applying Need-Driven Design in your software development, check out my next blog post: Consumer-expected, need-driven behavior development.