I Design Software by Pretending to Be the Code
August 27, 2026 · Johannes Werner

I Design Software by Pretending to Be the Code

TL;DR: When I struggle to understand a software system, I often pretend to be part of it. I ask what my responsibility is, what information I need, what I should know about the rest of the system and what the rest of the system needs from me. Then I switch perspectives. Combined with playing real scenarios through from beginning to end, this helps me turn abstract architecture discussions into concrete problems and usually leads to simpler solutions.

"Okay. You are the email service now."

I say things like this surprisingly often in architecture discussions.

It sounds a little ridiculous.

But when I don't understand where a responsibility belongs, I find it difficult to reason about the system while looking at boxes and arrows from the outside.

So I stop doing that.

I pretend to be one of the boxes.

What is my responsibility?

Let's say we're building a system that sends invitations by email.

At first, an EmailService sounds pretty straightforward.

But what does it actually do?

Instead of immediately thinking about methods, interfaces and dependencies, I start with a different question:

You are the email service. Why do you exist? What is your responsibility?

Maybe the answer is:

I send an email to one or more email addresses.

Good.

Now we can start asking questions.

Do I need to know about users?

Maybe not.

Do I need to know about organizations?

Why would I?

Do I need to know that someone is being invited to a campaign?

Probably not.

Do I need to decide who should receive an invitation?

That doesn't sound like sending an email anymore.

Do I need to generate the HTML for the email?

Maybe. Maybe not.

And suddenly a fairly abstract discussion about architecture becomes much more concrete.

I'm not trying to figure out what an EmailService usually looks like.

I'm trying to understand what this particular part of this particular system is responsible for.

Why do you know that?

One of the most useful questions in these discussions is also one of the simplest:

Why do you know that?

Imagine our email service receives an entire organization.

Why?

What is it going to do with it?

Perhaps the answer is:

It needs the organization to find all the users who should receive the email.

Okay.

But now we've learned something important.

Our email service isn't just sending emails anymore.

It is also resolving recipients based on our domain.

That might be exactly what we want.

Or it might tell us that we've put a responsibility in the wrong place.

We don't know yet.

But now we have something concrete to discuss.

Now switch sides

This is where I often change perspective.

Instead of being the email service, become the service on the other side.

Maybe we're now responsible for inviting all users in an organization.

What is our responsibility?

What information do we have?

What do we need from the email service?

Do we expect the email service to understand organizations?

Or do we expect to give it a list of recipients and some content and simply ask it to deliver the message?

Sometimes the answer becomes obvious when you look at the relationship from the opposite direction.

Sometimes it doesn't.

Those are usually the interesting discussions.

If one engineer thinks resolving recipients belongs to the email service and another thinks it belongs to the domain, pretending to be the boxes hasn't magically solved the problem.

But it has done something useful.

We've discovered what we actually disagree about.

Now we can discuss responsibility instead of arguing about interfaces and method names.

And sometimes those conversations get surprisingly philosophical.

That's fine.

At the end, I want everyone involved to understand why the boundary ended up where it did.

A service is allowed to be an adult

There is an obvious danger in thinking this way.

If you ask about every individual responsibility, you can end up with a system full of tiny services.

A RecipientResolverService.

An EmailTemplateRendererService.

An EmailSenderService.

An EmailDeliveryTrackerService.

An InvitationCoordinatorService.

Suddenly sending an email requires understanding half the architecture diagram.

That's not what I want either.

I sometimes think of components and services as adults.

An adult can have responsibility.

I don't want a service to be deliberately stupid just so that every class is tiny.

If something clearly belongs to its responsibility, let it own that responsibility completely.

Let it make the decisions it needs to make.

Let it have the information it needs.

Let it do its job.

I just don't want it wearing several unrelated hats.

For me, a good boundary isn't primarily about how many lines of code sit behind it.

It's about whether I can explain what that part of the system is responsible for without constantly adding "and also."

Sometimes I pretend to be the entire request

Thinking from the perspective of an individual part of the system helps me understand boundaries.

There is another thing I do when I don't understand the behavior of the system itself.

I say:

Okay, let's play it through.

And then we try to get as close to reality as possible.

No something happens here.

No we process the users.

No vague arrows between boxes.

We start with what actually happens.

Let's go back to the invitation example.

We're on the campaign page.

A user clicks the button to send invitations.

What happens?

We send a POST request to the backend containing the information we need.

What happens next?

The backend determines which organizations participate in the campaign.

What happens next?

We need the users belonging to those organizations.

What happens next?

We send the invitations.

Okay.

Now let's do it again.

What happens next?

I ask that question a lot.

And I often restart the scenario from the beginning while we're discussing it.

We're on the page.

We click the button.

We send the request.

The backend receives it.

We load the organizations.

We load their users.

Now what?

Repeating the parts we already understand helps everyone keep the same mental model in their head while we add another piece.

As long as the problem is small enough, I prefer doing this verbally.

It's fast.

If it becomes too complicated to keep in our heads, we'll sketch it.

But that's also sometimes a useful signal.

Maybe we're trying to reason about too many things at once.

In that case, we can stop, zoom into one part of the problem and continue there.

Now make the numbers uncomfortable

The first version of our invitation flow still sounds easy.

So let's put some numbers into it.

Suppose the campaign has 1,000 participating organizations.

Each organization has 10 users.

Now we're sending 10,000 emails.

Let's play it through again.

Do we load all organizations at once?

Do we load all their users?

What exactly do we keep in memory?

Do we send all 10,000 emails as part of the request?

What happens if something fails halfway through?

What happens if the process stops?

Do we even want the person who clicked the button to wait for all of this?

The numbers are intentionally uncomfortable.

I often do this because I believe that if something is logically sound, it should make sense with little data and with a lot of data.

Eventually, of course, hardware creates limits.

That's fine.

Hardware problems are often future problems.

Sometimes you can even throw more hardware at them and buy yourself time.

What I'm interested in first is whether increasing the numbers reveals an assumption we haven't thought about.

Large numbers don't mean you have to optimize for them

This distinction is important.

If I ask what happens with 10,000 emails, I'm not saying:

We need to build the system for 10,000 emails.

I'm saying:

Let's understand what would happen.

Once we've found a potential problem, we evaluate it.

Could this realistically happen?

If yes, we need to decide whether it deserves engineering time now.

If no, we can document it, perhaps create a ticket or ADR, and continue with the simpler implementation.

The exercise is not an excuse for premature optimization.

It's almost the opposite.

It gives us enough information to decide consciously not to optimize something.

Sometimes we'll calculate it.

Memory is a common example.

How much data are we actually loading?

How large can these objects become?

How much memory could this operation consume?

CPU can be another constraint.

Execution time is sometimes relevant too, but in my experience memory and CPU limits have a habit of becoming real problems surprisingly quickly.

The important part is that we're no longer discussing whether something "might scale."

We have a scenario.

We have numbers.

We can make a decision.

In this case, the architecture changed

The invitation example is from a real discussion.

Our initial instinct was to keep the implementation simple and process the invitations serially.

Then we played it through.

Once we looked at what could actually happen when the number of participants increased, we decided the process should be asynchronous.

That's the kind of architecture decision I like.

We didn't start with:

Email sending should obviously be asynchronous because that's how scalable systems work.

We started with the simplest thing we could imagine.

Then we tried to break the idea with a concrete scenario.

The scenario gave us a reason to introduce additional complexity.

So we introduced it.

Make the problem concrete before making the solution abstract

I think this is the common idea behind both techniques.

When I'm unsure about a system boundary, I pretend to be the code.

When I'm unsure about a process, I play the process through.

Both force me to stop reasoning entirely in abstractions.

A lot of architecture discussions start with abstractions:

Should we have another service?

Should this be asynchronous?

Should these modules be separated?

Should this data live here or there?

Should we introduce a queue?

Should this service depend on that one?

Those are useful questions eventually.

But I find them much easier to answer after making the problem concrete.

What actually happens?

What data actually moves?

Who actually needs it?

How much data are we talking about?

What is this service actually responsible for?

Why does it know about this other part of the system?

What does the other side expect from it?

Once I understand those things, the abstraction often becomes much less controversial.

Examples first, abstractions second

This is probably the biggest difference in how I approach these discussions.

You can start with an abstract model, make architectural decisions and then use examples to explain why the model works.

I prefer going in the opposite direction.

Start with an example.

Make it concrete.

Play it through.

Change perspective.

Try larger numbers.

Find the responsibilities.

Find the information that needs to cross boundaries.

Then abstract what you've learned.

That doesn't guarantee that the resulting architecture is perfect.

Nothing does.

But it gives the architecture a reason to exist.

This isn't limited to code

I use the same way of thinking outside software architecture.

Sometimes the thing I pretend to be isn't a service.

It's another person.

What does this person already know?

What do they need to know to understand what I'm about to tell them?

How could they interpret this information?

How might they respond?

What would I think if I were sitting on the other side of this conversation?

It's useful when preparing stakeholder conversations.

It's useful in product discussions.

It's useful when working with design.

It's useful when explaining something to another engineer.

The underlying technique is the same.

Change perspective.

Understand what the other side knows, needs and is responsible for.

Then look at the interaction again.

In that sense, I think there is a surprising amount of empathy involved in designing software.

Not because services have feelings.

But because perspective taking is a useful way to understand relationships, even when the things on either side of that relationship happen to be pieces of code.

The goal isn't better architecture

At least not directly.

My goal is to understand the problem well enough that we can implement the simplest solution that solves it.

That's important because simple systems usually require less maintenance.

They're easier for other engineers to understand.

They're easier to change.

And if the responsibilities and boundaries are logical, something else tends to happen.

Months later, a requirement appears that nobody predicted.

You look at the existing system and realize the new behavior already has an obvious place to go.

Not because you designed the architecture for that future requirement.

You didn't.

You couldn't have.

It fits because the responsibilities still make sense.

Those are some of my favorite moments in software development.

You discover that a system is easy to extend not because you tried to make it future-proof, but because you understood the original problem well enough to keep the solution simple.

So when I'm stuck in an architecture discussion, I rarely start by looking for another pattern.

Instead, I become the code.

What is my responsibility?

Why am I here?

What do I need to know?

What shouldn't I know?

What does the other side need from me?

And if that isn't enough:

Okay. Let's play it through.

This is how I build Vuesion

These aren't just ideas I write about. They're principles I've been building into Vuesion for more than eight years.

See how Vuesion works →