- Published on
The ambiguity of requirements: features, questions, understanding
- Authors
- Name
- Damian Płaza
- @raimeyuu
How can we help?
Meet Tom (you might recall him from Many Faces of DDD Aggregates in F#) and from The ambiguity, the curse and the fallacy of domain model).
As a successful businessman, after he earned tons of money with disco clubs and creating video games, with his business nose, he felt there's a niche in the market.

Hi there! Long time, no see.
I got an amazing idea - "thanks" to LLMs and AI technologies people forget things easily.
Can you help me with building a service around it?
It will have a bunch of features, but nothing too complicated.
A division?
It seems we need to explore this area a bit more.
Good that we have Tom to guide us and we are pretty skilled with collaborative exploration.
Let's have a quick event storming session! (because that's what cool kids do, right?)
"Ok, tell us more about the division - what is it all about?", we asked.
After this little question, we spent 1 hour exploring Tom's idea (during which Tom mostly rambled about how great it is) and it turned out we didn't capture that many business facts.

It was quite hard to cut in Tom's enthusiastic monologue, so we ended up with not-so-clear idea of his idea (pun intended).
Specification by example to the rescue!
Maybe it's time to involve Tom in more concrete examples and scenarios to clarify his idea?
Definitely we should go and ask him to walk us through a few specific cases.
It will help us with implementing this feature.

Special cases?
And we went into executable specifications mode, trying to nail down the details of his idea.
Outside-In development perspective is deeply rooted in our heart, so we started from writing down a need-driven, executable, acceptance criteria.
[Theory]
[InlineData(10, 2, 5)]
[InlineData(9, 3, 3)]
[InlineData(7, 2, 3.5)]
public async Task given_dividend_and_divisor_when_dividing_then_returns_expected_result(int x, int y, double expected)
{
var response = await _client.GetAsync($"/divide/{x}/by/{y}");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<double>();
Assert.Equal(expected, result);
}
Next step in our 3S workflow is to satisfy the specification, we write down the implementation to make the tests pass.
app.MapGet("/divide/{x}/by/{y}", (int x, int y) => (double)x / y);
And puff, tests got green.
Tom gave us happy-path examples and of course we asked him about "the special case".
"You don't know what is the 'special' case of division? I told you people will forget that!", he yelled.
"Dividing by zero, of course!", he continued, a bit calmer this time.
So we captured that as an executable specification too.
[Fact]
public async Task given_a_dividend_and_zero_as_divisor_when_dividing_then_returns_unprocessable_entity_with_problem_details()
{
var response = await _client.GetAsync("/divide/10/by/0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal("Cannot divide by zero.", problemDetails?.Detail);
}
Back to satisfying the specification, we extend the handler to cover the zero-divisor case.
app.MapGet("/divide/{x}/by/{y}", (int x, int y) =>
y == 0
? Results.Problem(detail: "Cannot divide by zero.", statusCode: StatusCodes.Status422UnprocessableEntity)
: Results.Ok((double)x / y));
It's a bit of a shame that we didn't figure it out earlier, but at least we have a clear specification now, right?
Close collaboration definitely helped distilling the domain part of the problem. We could follow the 3S workflow and follow up the simplification phase and refactor toward a real domain model.
So the arithmetic and its failure mode moved out of the handler and into a Calculation that knows how to Divide.
public static class Calculation
{
public static Result<double> Divide(int x, int y) =>
y == 0
? Result.Failure<double>("Cannot divide by zero.")
: Result.Success((double)x / y);
}
And the handler shrunk down to translating that result into an HTTP response.
app.MapGet("/divide/{x}/by/{y}", (int x, int y) =>
{
var result = Calculation.Divide(x, y);
return result.IsSuccess
? Results.Ok(result.Value)
: Results.Problem(detail: result.Error, statusCode: StatusCodes.Status422UnprocessableEntity);
});
Tests stayed green, but now the rule "you cannot divide by zero" lives in one obvious place instead of being tangled with routing and HTTP status codes.
That's the software engineering way: yeehaa!
Because we were modelling together, Tom saw the code and wasn't even impressed by the usage of Result.
Poor Tom is not aware of monads.
We could also improve the code and map, don't ask.
app.MapGet("/divide/{x}/by/{y}", (int x, int y) =>
Calculation.Divide(x, y).Match(
onSuccess: value => Results.Ok(value),
onFailure: error => Results.Problem(detail: error, statusCode: StatusCodes.Status422UnprocessableEntity)));
Functional-first thinking baby!
This time we don't even show it to Tom, he wouldn't admire the greatness of it.
Ah, we also forgot about publishing events - as we explored the domain earlier, wouldn't it be a waste if we didn't do it?
Let's add missing specs.
[Fact]
public async Task given_a_dividend_and_a_non_zero_divisor_when_dividing_then_publishes_division_calculation_completed()
{
var eventTracker = new FakeEventTracker();
var client = _factory
.WithWebHostBuilder(builder => builder.ConfigureServices(services =>
services.AddSingleton<IPublishEvent>(eventTracker)))
.CreateClient();
await client.GetAsync("/divide/10/by/2");
var completed = Assert.IsType<DivisionCalculationCompleted>(Assert.Single(eventTracker.Events));
Assert.Equal(10, completed.Dividend);
Assert.Equal(2, completed.Divisor);
Assert.Equal(5, completed.Result);
}
[Fact]
public async Task given_a_dividend_and_zero_as_divisor_when_dividing_then_publishes_calculating_division_failed()
{
var eventTracker = new FakeEventTracker();
var client = _factory
.WithWebHostBuilder(builder => builder.ConfigureServices(services =>
services.AddSingleton<IPublishEvent>(eventTracker)))
.CreateClient();
await client.GetAsync("/divide/10/by/0");
var failed = Assert.IsType<CalculatingDivisionFailed>(Assert.Single(eventTracker.Events));
Assert.Equal(10, failed.Dividend);
Assert.Equal(0, failed.Divisor);
Assert.Equal("Cannot divide by zero.", failed.Reason);
}
private class FakeEventTracker : IPublishEvent
{
public List<object> Events { get; set; } = new();
public Task Publish<TEvent>(TEvent @event) where TEvent : notnull
{
Events.Add(@event);
return Task.CompletedTask;
}
}
Specs complete!
Are we done?
The M-word?
What is the M-word that Tom is mostly interested in?
No, it's not Monads, sorry.
It's M-oney.

Finally, we talk about money.
I saw this division in action, but it's nothing amazing, to be fair.
As soon as people start forgetting how to divide because of LLMs, we are going to be rich.
You might wonder - how?
That is simple.
They will not notice it, because it'll be so small.
Millions of users, times a small amount, adds up quickly.
Division feature was super easy, the domain was trivial - we know how to divide.
Ok, charging users might be more interesting.
Thankfully, we know that communication at the right level is vastly important and that collaboration is the key to success - that's why we invited Tom back to the event storming board.

If you listened carefully, dear Reader, you might have noticed that there "whenever" keyword appeard in Tom's message.
If we take an interpretation of Alberto's picture that explains (almost) everything, you might recall we might be talking about a policy.

It sounded as if we are going to transform DivisionCalculationCompleted into a command ChargeUser that will eventually lead to a fact UserCharged happening.
That's definitely our a brilliant observation - domain modelling in its fullness!
Tom was not that impressed - we put some more stickies, but for him it was pretty obvious - that's what he exactly said, isn't it?
We tried to tell him what we have discovered, why events are important, what is a command, and what a policy means - but he wasn't that interested.
It's not his area of expertise, maybe that's why he is not seeing the depth of our work.
To complete the session, let's add missing commands.

Seems like we understood what is required by Tom, let's finally go to the code.
We are charging a user, so definitely we need to create the domain model that will represent this concept of a User.
What information do we need User to have in order to satisfy Tom's requirements?
Definitely some identification - who they are.
Then some details needed to charge them - for transferring money.
We quickly sketched what "charging a user" probably needs. Surely, a User with some billing details bolted on will do?
public class User
{
public Guid Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string CreditCardNumber { get; set; }
public DateTime CreditCardExpiry { get; set; }
}
Looks reasonable, doesn't it? A name, an email, and just enough fields to charge someone.
Of course we know that our models should be focused on behaviors rather than just data.
But we will express it as an executable spec!
"Whenever"
Thankfully, we listened carefully and we know that a policy means a reaction - so we need to create an event listener that will do the proper transformation of incoming event into a command.
[Fact]
public async Task given_a_dividend_and_a_non_zero_divisor_when_division_calculation_completed_then_user_is_charged_with_tiny_fee()
{
var user = new User
{
CreditCardNumber = "4111111111111111",
CreditCardExpiry = new DateTime(2030, 1, 1),
BaseCalculationFee = 0.01m
};
var gateway = new FakePaymentGateway();
var client = _webApplicationFactory
.WithWebHostBuilder(builder => builder.ConfigureServices(services =>
{
services.AddSingleton<IUsersRepository>(new FakeUsersRepository(user));
services.AddSingleton<IPaymentGateway>(gateway);
}))
.CreateClient();
await client.GetAsync("/divide/10/by/2");
var transfer = Assert.Single(gateway.Transfers);
Assert.Equal(user.CreditCardNumber, transfer.CardNumber);
Assert.Equal(user.CreditCardExpiry, transfer.CardExpiry);
Assert.Equal(user.BaseCalculationFee, transfer.Amount);
}
private class FakeUsersRepository : IUsersRepository
{
private readonly User _user;
public FakeUsersRepository(User user) => _user = user;
public Task<User> GetAsync(Guid id) => Task.FromResult(_user);
}
private class FakePaymentGateway : IPaymentGateway
{
public List<PaymentDetails> Transfers { get; set; } = new();
public Task Transfer(PaymentDetails details)
{
Transfers.Add(details);
return Task.CompletedTask;
}
}
}
Of course, the spec fails as there is no logic built-in that will ensure the required behavior - let's add it!
First, IPublishEvent needs a partner in crime - something that lets a reaction subscribe to the events flowing through it.
public interface IHandleEvent<TEvent>
{
Task Handle(TEvent @event);
}
Object providing this capability will get the easiest (requiring the lowest effort) implementation - so we'll omit it for brevity.
User is going to be much more interesting element, because we want it to be fully object-oriented.
What does it actually mean?
A User knows how to charge itself, given a gateway and its own tiny fee - behavioral modelling for the win!
public class User
{
public Guid Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string CreditCardNumber { get; set; }
public DateTime CreditCardExpiry { get; set; }
public decimal BaseCalculationFee { get; set; }
public async Task Charge(IPaymentGateway @using)
{
var gateway = @using;
await gateway.Transfer(new PaymentDetails(CreditCardNumber, CreditCardExpiry, BaseCalculationFee));
}
}
The policy, the event handler, works with its collaborators to actually deliver specified behavior.
public record DivisionCalculationCompletedEventHandler(
IUsersRepository Users,
IPaymentGateway Gateway
) : IHandleEvent<DivisionCalculationCompleted>
{
public async Task Handle(DivisionCalculationCompleted @event)
{
var user = await Users.GetAsync(@event.CustomerId);
await user.Charge(@using: Gateway);
await Users.SaveAsync(user);
}
}
And finally, the policy subscribes to a particular event - DivisionCalculationCompleted.
builder.Services.AddScoped<DivisionCalculationCompletedEventHandler>();
builder.Services.AddSingleton<IPublishEvent>(services => new InMemoryMessageBus(services));
var bus = (InMemoryMessageBus)app.Services.GetRequiredService<IPublishEvent>();
bus.Subscribe<DivisionCalculationCompleted, DivisionCalculationCompletedEventHandler>();
We execute the specification we wrote earlier and...It's ✅.
Software works exactly as we understood it to, from the expectations we were told to represent.
How cool is that?!
We presented the specifications to Tom so that he could approve our understanding - because we used business language in the spec names, it should be straightforward for Tom to verify.
We got ✅ from him too.
Nice!
A side-effect of our design with events is that in case of scaling - we can distribute the event handling across multiple instances or services, allowing for better performance and reliability.
Our architecture is very simple - it contains two elements:

It does not look like those overengineered architectures you often see in presentations.
Simple requirements, simple architecture, right?!
Change, change never changes
Tom went out to check our newly developed services.
Some time passed and he came back with interesting observations.

Our users love the new service!
As I predicted, people started forgetting how division is working and they got hooked!
Some users are not happy with charging by the usage.
Nice idea, isn't it?
Preloaded money seems like a good idea - our architecture could definitely support it out of the box.
We designed the domain model taking into the account the behavioral level everything will be encapsulated in the Charge behavior of User.
It looks as an easy feature, so we don't bother Tom with event storming session - we asked him directly about how to determine charging method.

Configuration - of course!
Can you figure it our dear Reader where how this change is going to look?
I bet you can.
public class User
{
public Guid Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string CreditCardNumber { get; set; }
public DateTime CreditCardExpiry { get; set; }
public decimal BaseCalculationFee { get; set; }
public bool IsPrepaid { get; set; } // 👈 new information!
public decimal PrepaidAmount { get; set; } // 👈 new information!
public async Task Charge(IPaymentGateway @using)
{
if(IsPrepaid) // 👈 new section!
{
if(PrepaidAmount >= BaseCalculationFee)
{
PrepaidAmount -= BaseCalculationFee;
return;
}
else
{
throw new InvalidOperationException("Insufficient prepaid amount.");
}
}
var gateway = @using;
await gateway.Transfer(new PaymentDetails(CreditCardNumber, CreditCardExpiry, BaseCalculationFee));
}
}
It turned out quickly that with the current organization of elements, when a user has prepaid charging configured, they go below the limit after the division calculation was completed - so they already have used the service.
The worst thing was that we didn't want to couple calculating division with information about users - in order to control it on the calculation side, we would need to read the user's prepaid status and amount.
We tried to explain it to Tom in terms of best practices, but Tom wasn't particularly happy about not earning money.
Also, after the initial testing with the real users, it might be that we would need to introduce some kind of a limits for each user? At least that's what Tom said briefly.

I have another brilliant idea for a feature.
This is very interesting feature.
How will a User look like after introducing that change?
High chances are that there will be some kind of a BaseDelay.
public class User
{
public Guid Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string CreditCardNumber { get; set; }
public DateTime CreditCardExpiry { get; set; }
public decimal BaseCalculationFee { get; set; }
public TimeSpan BaseDelay { get; set; } // 👈 new information!
}
Ok, after this change we would definitely couple users and calculations.
We swallow a hard pill and let it be - if a fellow architect asks, we would tell them that Tom wanted that, according to his specs, right?

So if they want to get more precision, they need to pay us.
The sooner we ship it, the better.
Even though it might sound complicated, it is fairly easy and straightforward.
It sounds like yet another configurable parameter that user can select and pay for.
public class User
{
public Guid Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string CreditCardNumber { get; set; }
public DateTime CreditCardExpiry { get; set; }
public decimal BaseCalculationFee { get; set; }
public TimeSpan BaseDelay { get; set; }
public int Precision { get; set; } // 👈 new information!
}
Ok, seems like we need to go and implement features in the core logic.
Since delay and precision now live directly on the User, the handler needs to know who is asking - a customer identifier joins the route, and the handler reaches straight into their record for the numbers it needs.
app.MapGet("/divide/{customerId}/{x}/by/{y}", async (
Guid customerId, int x, int y,
IUsersRepository users,
IPublishEvent events) =>
{
var result = Calculation.Divide(x, y);
if (!result.IsSuccess)
{
await events.Publish(new CalculatingDivisionFailed(x, y, result.Error));
return Results.Problem(detail: result.Error, statusCode: StatusCodes.Status422UnprocessableEntity);
}
var user = await users.GetAsync(customerId);
await Task.Delay(user.BaseDelay);
var trimmed = Math.Round(result.Value, user.Precision);
await events.Publish(new DivisionCalculationCompleted(customerId, x, y, trimmed));
return Results.Ok(trimmed);
});
"What's next boss?"
Let's slow down for a while and think.
We rolled out so many features, we used collaborative methods, applied functional programming, object-oriented design and many more.
We tried to control coupling between services.
Everything seems fine.
But what are the predictions about our little system?
User entity is getting bigger, attracts more and more information.
At the beginning, we focused so much of our quality time into exploring the details of dividing, but it somehow feels odd.
The more features we got on our plate, the more it seems it is just an element of the entire puzzle.
Hm, we tried to apply all those industry best practices, at least when it comes to working together, as a team.
Literally, whatever Tom brought to us, we listened carefully and tried to build up understanding and apply it, representing important concepts like Calculation, User.
"It is a toy example Damian, in real systems no one is so naive as you tried to be", some might actually say.
Well, I didn't say we are going to build a big system during this tale.
If we take John Gall's law:
A complex system that works is invariably found to have evolved from a simple system that worked.
It might be that our toy example will eventually grow into something bigger, even if it looks deceivly simple at the moment.
We started with a simple, but central division feature, and ended up as it being pushed to the side, in favor of other aspects of the system.
Aspects of the system or aspects of the business?
What a calculator has to do with a business?
Imagine that our initial conversation with Tom looked slightly different.
Tom had his ideas, brilliant ones, not to diminish them.
But they needed to be worked on, at least slightly.
We used our way of thinking to understand and refine them.
Analyze how to decompose concepts firstly, sometimes suggesting, a bit speculatively, how to organize them.
After a short discussion which started from a simple division and users, we ended up with ideas about:
- users
- customers
- calculation tokens
- calculation orders
- billing
"This is so Big Design Up-Front Damian!", some might resist.
Well, what if those concepts were there, but uncovered?
Many of the businesses and human activities have common ideas, especially when it comes to universal concepts like selling/ordering/billing, and so on.
Who's fault, I mean responsibility, is to make those concepts explicit?
Often times, our domain experts or business owners are the ones who have the most knowledge about their area of expertise, but they are not trained in abstract thinking.
A particular way of experiencing the world around.
So what a calculator has to do with a business?
In a way, nothing and everything at the same time.
It is a basic capability that we are selling an access to.
Initially, Tom did not reveal us "the depth" of this area - but when we consciously explored it, trying to understand the main ideas driving the whole endeavor, it seems we saw this "depth".
Of course it required being proactive and also suggest organizing the concepts in the certain way.
Not only receiving what Tom told us, but also process, reflect and pushback.
"The depth" that got mentioned, might be directly related to different levels at which our concepts live.
Those "levels" appear in the literature in various forms, but the universal pattern is similar - our systems are not "flat", and there are various dimensions that we should use while we're analyzing, understanding, decomposing systems.
Same with our tiny tale:

Doing all of that work will eventually lead to User concept disappearing, at least in the form we experienced it in this tale.
It also enriches the language we might use while talking to each other.
An avid Reader might have already noticed a subtle feedback given by earlier specs, when it comes to a concept of User:
var user = new User
{
CreditCardNumber = "4111111111111111",
CreditCardExpiry = new DateTime(2030, 1, 1),
BaseCalculationFee = 0.01m
};
Even though we create a new instance of User object, the information used in the test are focusing on a very specific perspective of it.
Coincidentally mixing up aspects and creating the domain model might sound easy, but definitely it is not simple.
Even tests were whispering to us, giving diagnostic signals, but it strongly depends on our ability to listen to them.
Requirements? Features? Concepts?
Often times, we get "the requirements" which turn out to be unprocessed thoughts - they might have their value, but still they need a little bit more effort when it comes to ensuring they fit the conceptual integrity of our system.
Those "requirements" or "features", branded as "simple", hide interesting and important abstract ideas behind.
And this is our job to check how they interact with the rest of the system.
But when I say "interact", it does not only mean "technically interact" - method calls, sending HTTP requests and so on.
It also means "conceptually interact" - as you experienced that earlier dear Reader, when we had a lengthy discussion with Tom.
Before jumping to any coding or solutioning, we should think about composing a PIE.
Two questions I find particularly interesting, powerful and useful, are:
What does X mean to me?
or
How do I understand X?
They require me to enter analysis mode where I try to purposefully explore the problem area.
Each requirement, feature or expectation, has deeper structure buried underneath.
Unfortunately, they don't communicate this conceptual structure explicitly and immediately.
It requires an investment, human effort, to lean over it and start seeing underlying ideas.
Recall dear Reader what Tom initially told us - "it is a simple division" - we got hooked in by this framing and the rest is just history.
That's why naming is framing - as soon as the label/name appears, it shapes the world we experience and we see.
It requires stepping outside of the invisible box, the invisible frame, and start asking questions:
- "Why is it important?"
- "What are the main questions are we looking answers to?"
- "What is the goal?"
- "What do we try to accomplish when doing X?"
- And so on...
Those questions are there not to critique, but to understand.
To synthesize the knowledge, and frame the right conceptual structure that will hold the rest of the system.
We must do it, because the information we often initially get is unstructured, ambiguious and contradicting.
Ambiguity does not compile.
Software design is about untangling ambiguity and organizing various structures - but please don't conflate them with "data structures".
It's far more than that.
More like building theories, however, I would rather brand it as "conceptual structuring" or "organizing conceptual structures".
It is necessary effort, but insufficient, on its own.
Our software systems live in numerous dimensions - and conceptual is only one of them.
We have runtime as well.
So those technical matters are still greatly important, because slowly responding, but perfectly modeled systems is not particularly useful for users or customers.
Ambiguity does not compile.
Software design might be regarded as a continuous process of searching deeper conceptual structures.
It strongly depends on the problem area we are operating in - tools solving a particular problem might be bounded enough so that the search might end pretty quickly, whereas business environments can cause neverending forces that will require concepts to die, emerge and transform.
Everything starts with a question
The quality of questions is the quality of answers.
As someone once said, "A question contains the half of the answer".
As naming is framing, framing questions is framing the way we think.
We can use various tool like event storming, example mapping, specification by example and more, but if we don't look for deeper conceptual structures, "the theories", we might just slight on the surface.
If we remember that tools make fools, we might resist the temptation to rely solely on them.
Clearly thinking operator, the designer who know how to build, is the secret sauce here.
Clear thinking gets amplified, noisy thinking gets amplified too.
Often times the clarity comes from collaboration, but as we saw, at least one side of the conversation needs to be able to exercise the deep conceptual thinking.
A simple division might hide the complexities of business systems, only if the operator is capable of reasoning using the variety of dimensions.
Domain-Driven Design might be frequently conflated with "tactical patterns", but essentially it is a simple (but not easy, which means effort-requiring) call to collaborate in order to establish conceptual organization, where the ambiguity is primarly observed.
It might be that we are facing a basic business capability, like division, that is surrounded by other contexts, which in the first glance are invisible.
There are certain business operations that utilize those capabilities, and the specificity of the logical order of interactions, is what might be truly volatile, hence requires encapsulation - a true criteria for decomposing a system.
So next time dear Reader, when you hear "a simple requirement", ask yourself:
What does it mean to me?