Skip to content

On Intent

Everyone slaps "intent" onto everything

Makonea
Β·Apr 23, 2026Β·22 min

Introduction

These days, when people talk about programming, the buzzword is Intent. As with every trendy keyword, intent is declared important, so now it gets attached to architectures (MVI), bolted onto programming methodologies (Intent-Driven Programming), and generally causes quite a stir.

The substance is fine. We should value intent. We should understand the consumer's intent. We should grasp the end user's intent and incorporate it into programming.

And so on β€” the rhetoric is truly grand.

The problem is that when you actually read through these posts, it becomes clear that "intent" is a multi-layered concept.

We casually use the phrase "good abstraction," yet anyone who has actually written code has repeatedly experienced a design that looked clean at first gradually falling apart over time. This phenomenon is not simply a matter of mistakes or skill level. The problem runs deeper. The process by which intent is translated into a real system passes through multiple layers of abstraction, and the information loss and context-dependence that occur between those layers are the root cause.

(The Structure of Scientific Revolutions, Thomas Kuhn)

The way we paper over the problem

My own view of this concept of Intent is that

it resembles Thomas Kuhn's "paradigm."

The trouble is that this word has been used far too often.

Kuhn himself, in The Structure of Scientific Revolutions, is criticized for using the term "paradigm"

in more than 20 distinct senses.

(Masterman's classic critique)

In other words, rather than being a precise theoretical term, this concept is closer to

a meta-concept whose meaning shifts with context.

As a result, in contemporary software discussions, the word "paradigm"

is often used not to explain something but to avoid explaining it.

The same pattern appears in the recently fashionable "Intent" discourse.

The concept of intent is similarly left undefined,

and used as a term that points to multiple layers of meaning simultaneously.

- User requirements

- Business goals

- Domain model

- Code-level intent

When all of these are bundled under the single word "intent,"

the problem is not simplified but actually obscured.

Rather than treating intent as a single unified concept, therefore,

we need to decompose it layer by layer.

Viewed that way, Intent is not one thing;

it is a collection of multiple intents defined at different levels.

The examples listed above make this clear.

- User intent (UX level)

- Domain intent (business rules)

- System intent (architecture decisions)

- Implementation intent (code-level choices)

These share the same word,

yet each carries different constraints and different optimization criteria.

At this point, one more important shift is needed. Instead of framing the problem in terms of "developer skill level" or "design completeness," we should look at how intent is translated into a system in the first place. Intent is not directly transcribed into code. Several stages lie in between.

Code
Intent
β†’ Domain model
β†’ Abstraction (architecture, interface)
β†’ Code
β†’ Compilation
β†’ Runtime environment
β†’ Hardware translation

This process is not a simple conversion; at each stage, information is discarded and the remaining content is interpreted in a particular direction.

Information is lost when extracting core business logic from intent into a domain model, when converting that model into abstractions, and again when writing the code. What matters most about this information loss is not merely that precision degrades, but that each loss narrows the options available afterward.

And

once an abstraction is chosen, it constrains every expression that follows.

Why abstractions always break down in practice

If broken abstractions were a matter of design completeness, skilled developers who design carefully would solve the problem. Reality says otherwise. Even abstractions designed by skilled developers fall apart over time. The reason is that the act of choosing an abstraction constrains the options available afterward.

Case 1: Repository Pattern and Transaction Boundary

The intent of the Repository Pattern is clear: separate data-access logic from the domain layer. Designed by the book, it looks like this.

C#
public interface IOrderRepository
{
    Order GetById(int id);
    void Save(Order order);
}

public interface IInventoryRepository
{
    Inventory GetByProductId(int productId);
    void Save(Inventory inventory);
}

Two Repositories are composed in the service layer.

C#
public class OrderService
{
    private readonly IOrderRepository orderRepo;
    private readonly IInventoryRepository inventoryRepo;

    public Result<OrderId, OrderError> PlaceOrder(PlaceOrderCommand command)
    {
        var inventory = inventoryRepo.GetByProductId(command.ProductId);

        if (inventory.Stock < command.Quantity)
            return Result.Failure(OrderError.OutOfStock);

        var order = Order.Create(command);
        inventory.Decrease(command.Quantity);

        orderRepo.Save(order);       // DB hit 1
        inventoryRepo.Save(inventory); // DB hit 2

        return Result.Success(order.Id);
    }
}

The design is clean. The problem is that if the process dies between orderRepo.Save and inventoryRepo.Save, the order has been created while the inventory remains unchanged.

To add a Transaction, what do you do? The most common choice is to add IUnitOfWork.

C#
public interface IUnitOfWork
{
    void BeginTransaction();
    void Commit();
    void Rollback();
}

This is the moment the abstraction starts to crack, though the reason it cracks does not lie in the abstraction itself.

Rather, an assumption that was implicitly baked into the interface from the very beginning no longer holds in the new environment.

IOrderRepository and IInventoryRepository appeared to express nothing more than "separate DB access," but in reality a stronger assumption was embedded alongside that intent: the assumption that both Repositories live within the same transaction context. This assumption is written nowhere in the interface signature, not in the method names, not in the return types. Yet the moment Save was made a callable unit, the assumption that it would be called within a shared commit boundary was implicitly locked in.

Introducing IUnitOfWork is the act of making this implicit assumption explicit. It does not add a new assumption; it surfaces the assumption that was there from the start and elevates it to the interface level.

When you migrate to a microservices architecture, this assumption breaks. If OrderRepository and InventoryRepository live in separate services, a shared DbContext is impossible to begin with.

At that point the options are the Saga pattern or 2PC, and both of those patterns operate on a different premise: not "calling Save persists the data" but "publishing intent eventually achieves consistency." Even if the method signatures look similar, the underlying consistency model is fundamentally different.

That is, the "single transaction context" assumption baked into the original Repository interface is incompatible with the consistency model of the new environment. Swapping the implementation while keeping the interface intact is not enough to fix this, because the semantics themselves have changed, and the calling code's control flow must change along with them.

Where to draw the Repository boundary is an intent-level decision.

Yet that decision collides with a runtime constraint called Transaction Boundary. These two layers of intent had different optimization criteria from the start, and at design time this collision is invisible.

Case 2: Event-Driven Design and the Demand for Synchronous Responses

The intent of an event-driven architecture is to reduce coupling.

Since developers working in C# are generally comfortable with event-driven coding, it is not particularly hard to separate inventory deduction, notification dispatch, and point accumulation from the order-creation flow, publishing events instead of calling them directly.

C#
public class OrderCreatedEvent
{
    public int OrderId { get; init; }
    public int ProductId { get; init; }
    public int Quantity { get; init; }
    public DateTime OccurredAt { get; init; }
}

public class OrderService
{
    private readonly IEventBus eventBus;

    public OrderId PlaceOrder(PlaceOrderCommand command)
    {
        var order = Order.Create(command);
        // ... persist order

        eventBus.Publish(new OrderCreatedEvent
        {
            OrderId = order.Id,
            ProductId = command.ProductId,
            Quantity = command.Quantity,
            OccurredAt = DateTime.UtcNow
        });

        return order.Id;
    }
}

InventoryHandler, NotificationHandler, and PointHandler each subscribe to the event. Coupling is low, and when a new handler needs to be added, OrderService does not need to be touched. The intent appears to have been realized cleanly.

About three months in, the boss reaches out with a new requirement.

"When an order completes, please show the inventory deduction result on screen in real time. If stock is insufficient, the order itself should be marked as failed."

This requirement breaks the fundamental premise of the event-driven design. Events are fire-and-forget: the publisher has no knowledge of the subscriber's processing result. To include the inventory deduction result in the response, a synchronous call is required.

There are three options, and all of them carry a cost.

Option A: Add a synchronous inventory check before publishing the event

C#
public Result<OrderId, OrderError> PlaceOrder(PlaceOrderCommand command)
{
    var stockResult = inventoryService.CheckAndReserve(command.ProductId, command.Quantity);

    if (stockResult.IsFailure)
        return Result.Failure(OrderError.OutOfStock);

    var order = Order.Create(command);
    eventBus.Publish(new OrderCreatedEvent { ... });

    return Result.Success(order.Id);
}

Event-driven and direct-call approaches become intermixed. OrderService directly knows InventoryService. The coupling that was originally removed by using events is reintroduced.

Option B: Extend the event bus with a Request/Reply Pattern

C#
var reservationResult = await eventBus.Request<ReserveStockCommand, StockReservationResult>(
    new ReserveStockCommand { ProductId = command.ProductId, Quantity = command.Quantity }
);

The event bus must support synchronous responses, which increases infrastructure complexity. At that point it is no longer really an event bus but something closer to a message broker. This means using something like RabbitMQ's RPC pattern or MediatR's Request/Response, and the design drifts further and further from the original event-driven intent.

Option C: Redesign from scratch

This option formally exists but is almost never chosen. The reason is straightforward. The already-written handlers, the events already being published, and the consumers from other teams already depending on them make the cost of redesign asymmetrically high. When the EventBus abstraction was chosen, that cost did not exist. But as code accumulates on top of that abstraction over six months, the cost of redesign grows continuously.

Option A ends with a small patch, Option B requires infrastructure expansion, and Option C means rewriting all accumulated code.

The costs of the three options are not on the same scale. For a freelance developer like me, Option C is nearly impossible to choose. I know it is the better option. But my time is my money.

And this cost asymmetry is itself the result of the initial abstraction choice. In practice, most teams go with Option A, and the service solidifies into a state where event-driven and direct-call patterns are mixed together.

What matters is that this is not a one-off accident but a recurring pattern. The same form of collision arises in different teams and different domains in exactly the same way.

The reason lies at the root of the EventBus abstraction itself.

These paths of communication cannot be dynamically created and discovered at runtime; they need to be agreed upon at design time so that the application knows where its data is coming from and where the data is going to.

Hohpe & Woolf, Enterprise Integration Patterns (2003), p. 108, Chapter4 "Messaging Channels"

Hohpe and Woolf's Enterprise Integration Patterns (2003) divides message channels along two axes.

One axis distinguishes the Point-to-Point Channel, which delivers to a single receiver, from the Publish-Subscribe Channel, which broadcasts to all receivers. The other axis distinguishes unidirectional publishing from Request-Reply, which requires a bidirectional exchange. The standard EventBus implementation is built on the combination of Publish-Subscribe Channel and unidirectional publishing, commonly called fire-and-forget.

This choice of primitive determines the mechanism of coupling reduction itself. By definition, fire-and-forget means the publisher need not know the list of subscribers. Because it need not know, the publisher's code does not change when a new subscriber is added. This is the precise meaning of decoupling: not merely "OrderService does not directly call InventoryHandler," but "OrderService has no knowledge that InventoryHandler exists at all."

The moment this anonymity is broken, coupling is reintroduced. If the publisher must wait for a response,

the publisher must

(a) know who will send the response, or from whom a response is expected,

(b) know the format of the response, and

(c) decide on a timeout for when no response arrives.

None of these three exist in fire-and-forget. You can mimic the signature superficially (by adding a method like Request<T, R>), but that method's failure modes, timeout semantics, and idempotency requirements are different from those of fire-and-forget. When anonymity collapses, the decoupling built on top of that anonymity collapses with it.

In other words, the decoupling that EventBus provides holds conditionally on the fire-and-forget primitive. This condition is never stated anywhere in the interface signature; it sits implicitly inside the name "EventBus" and the Publish method.

This collision is not "a contradiction that was present from the start." At design time, the synchronous-response requirement did not exist, so there was no collision. But the moment the EventBus abstraction was chosen, the assumption "the publisher does not know the response" was nailed into the interface, and that nail narrowed the space available for future requirements.

The common structure across both cases is as follows.

Code
Design-time intent (decoupling / separating data access)
  -> Abstraction choice (Repository / EventBus)
  -> The implicit assumption encoded by the abstraction
     (single transaction context / fire-and-forget primitive)
  -> A new requirement demands a different assumption
  -> Signatures can be preserved, but semantics collide
  -> Patch, or redesign from scratch

This does not happen because the design is bad. It happens because abstraction is contextual.

The context at design time and the context when a new requirement arrives are different, and the abstraction cannot absorb that gap.

If "good abstraction" exists, it is not a design that predicts every future context, but a design that can absorb the friction cost when context changes.

And that standard cannot be defined at design time, nor does any developer exist who can solve everything.

What matters here is

that this problem is not the failure of any particular methodology.

Repository, EventBus, DDD:

all of them are attempts to structure intent at a specific point in time.

The problem is that once a separation criterion becomes established,

there is less room for different future intents to enter.

(Karpathy's post where "vibe coding" first appeared)

Karpathy described this as an improvisational workflow of "just vibing and running code through prompts," framing it positively. Yet that very improvisationality aligns precisely with the problem this post is examining.

An LLM takes the intent exposed in the current prompt

and rapidly generates a plausible abstraction,

while barely considering how that abstraction will constrain the space of future requirements.

This problem is not simply "LLMs write too much code."

More precisely, an LLM, working from the intent given in the current context,

optimizes patterns locally.

That is, it works from the requirements visible right now,

the constraints exposed in the current prompt,

and the separation criteria that seem most plausible at this moment,

and quickly assembles an abstraction around those.

The problem is that the intent of a real system does not end there.

Real-world intent always arrives with a delay.

Today's requirements are given explicitly,

while tomorrow's requirements are still latent, not yet put into words.

A human developer cannot fully predict the existence of these latent requirements,

but at least knows from experience that they will eventually arrive.

So when establishing an architecture,

the developer does not only ask "does this work now"

but also asks "will this break less easily later."

An LLM, by contrast, tends to take the intent visible in the current prompt

as its linguistic surface input.

As a result,

it very reliably produces designs that fit the current requirements well

but carry a high cost of future change.

This is one form of what is commonly called over-engineering.

What is important is that the over-engineering referred to here

does not simply mean having many classes and many files.

The real problem is

that by over-structuring the current intent,

the path by which a different, not-yet-arrived intent could enter

is actually narrowed.

In other words, the LLM creates abstractions.

But it has almost no sense of how much those abstractions lock down future options.

In this respect, the LLM's tendency to generate divergent output may not be an accidental mistake but something close to inevitable.

An LLM does not bear the cost of failure over the long term.

Whether the code will collide six months later with a difficult client's requirements,

which boundary will clash with transaction semantics,

which interface will become a bottleneck in a distributed environment:

that friction does not surface sufficiently in the language visible at generation time.

Ultimately, the LLM, working from the intent visible at the current moment,

produces what appears to be the most plausible architectural design.

But from my personal experience, a good architecture

is not the one that best expresses the current intent;

it is the one that can absorb the friction cost

even when a different future intent arrives.

And this very difference is,

I believe, the essential gap that exists between

a human developer's design experience and an LLM's generative capability.

For this reason, code produced by an LLM

often looks, on the surface, even more "well-designed" than code written by a human developer.

Interfaces are cleanly separated,

layers are clearly defined,

names sound convincing,

and the patterns are familiar.

The problem is that the boundary separations and patterns are not the result of resolving contextual constraints,

but the result of recombining patterns that appeared frequently in the training distribution.

The structure of the training signal itself makes it impossible to learn this friction cost.

When code is pushed to GitHub, it reflects how the code looked at the time of writing. The signs of retrofitting that occurred 18 months later, when transaction semantics created a collision, do not exist at that point. The snapshot that enters the training corpus cannot include that future friction, and the same holds when it enters the training data for the next generation of models. What is ingested is the current state of the repository, not its temporal trajectory.

Post-mortems and incident reports certainly exist in training data. But they are textually separated from the original code that caused the problem. A blog post titled "A case where the Repository Pattern broke down in a distributed environment" exists, but the original Repository interface that someone wrote cleanly 18 months earlier is not included alongside it. The signal linking the original pattern to its failure 18 months later is not bundled as a single unit within the training data.

The RLHF stage carries the same limitation. Annotators evaluate the aesthetics of the code currently on their screen. Where it will break 18 months from now is not part of the evaluation. Consequently, the reward signal the model receives is itself biased toward the aesthetics of the code at the time of writing.

Therefore, saying that an LLM does not know friction is not a metaphor; it is a direct consequence of the learning mechanism.

That is, the overall form of a pattern-selection outcome may be present,

but the historicity of the friction that the selection must bear is absent.

What humans learn through failure is not syntax but friction:

where drawing a boundary will cause transactions to tangle later,

which event separations will eventually revert to synchronous calls,

which abstractions look elegant at first but become poison after just two rounds of changing requirements. This kind of intuition is built mostly not through success

but through paying the cost of failure. An LLM simply does not carry the memory of that failure cost.

So the problem is not that the LLM does not know the patterns.

On the contrary, it knows them too well,

and in doing so erases the friction of the contexts where those patterns do not work.

In this respect, the dogmatization of methodology and the LLM's generative drift resemble each other.

Both carry the impulse to reduce a living context to a fixed structure.

(Refactoring to Patterns)

The true joy of patterns comes from using them wisely. Refactoring helps us do that by focusing our attention on removing duplication, simplifying code, and making code communicate its intention. When patterns evolve into a system by means of refactoring, there is less chance of over-engineering with patterns. The better you get at refactoring, the more chance you'll have to find the joy of patterns.

Joshua Kerievsky, Refactoring to Patterns (Addison-Wesley, 2005)

The fixation of context

Humans are inherently averse to complexity. So we discover "patterns" and try to simplify and explain complex systems.

The problem is that even though such simplifications are always dependent on context and circumstance, people sometimes become so convinced of them that they treat the simplification as the definitive answer. We see this in books on pattern programming, where authors describe their own experience of becoming absorbed in pattern programming.

In that sense, the way Intent is used functions as a clean higher-level concept that papers over complex things by reducing them to something simple.

With methodologies like TDD or pattern programming, without grasping the period and context in which they emerged,

their essential implications are set aside and the methodology is dogmatized, used as a kind of religious doctrine that must be followed without question.

Personally, I think TDD is, on average, an excellent methodology. But used incorrectly, it can become one of the worst.

If the abstraction is wrong, what meaning does testing have?

I have seen many cases where people chase passing tests while progressively destroying their architecture.

For another example, the SOLID principles do not always need to be followed, yet in some organizations they are treated almost like religious doctrine.

Applying LSP too rigidly in UI component design can impede the diversity and flexibility of the UI.

In the end, Intent is not a single concept.

Multiple intents coexist at the user level, domain level, system level, and implementation level, each with different constraints and different optimization criteria, and they conflict with one another.

Good design is not about eliminating these conflicts. It is about either delaying the moment when a conflict surfaces, or creating separation criteria that can absorb the friction cost when a conflict does surface.

That intuition does not come from knowing the patterns. It comes from having experienced the contexts where the patterns do not work.

Talking about intent is easy. Knowing what is lost each time intent passes through a layer is hard.

And knowing that, in the end, seems to be my goal.

On Intent