A model client, a routing rule and a conversation record need different kinds of ownership. Use resource-owning objects for live connections and pure functions for decisions over explicit data. Keep conversation state explicit so either side can inspect it without depending on a provider's client.
Responsibilities determine where objects belong
A client and a routing rule need different owners
An agent application makes the choice concrete. A provider client holds a connection and credentials. A routing rule decides what should happen after a model response. A conversation record describes what has happened so far. They participate in one operation, but the reasons each might change are different: the provider changes its API, the team changes a policy, or a new event updates the conversation.
A useful architecture gives those changes somewhere to happen without dragging the other two along. The provider client can be an object. The routing rule can be a function over explicit values. The conversation can be data that both understand. That arrangement gives each programming style a job you can explain.
The alternative is a debate conducted at the level of brand names. OOP buys encapsulation. FP buys purity. Fine. Which state needs encapsulating? Which decision benefits from purity? What will the next person have to understand before changing either one?
I take the portfolio view. A position has a purpose, a cost and a condition under which you'd reduce it. Holding an asset because its category is respectable tells you very little about whether it belongs in this portfolio. Keeping a class because serious applications have classes is the same kind of decision.

A position has costs and conditions
The position I favor for agent orchestration is a functional core that makes decisions over explicit data, surrounded by an imperative shell that performs effects and owns resources. Composition handles capabilities that vary independently. A shared intermediate representation becomes useful when several integrations need the same internal meaning.
Those choices solve different problems. You can use one without buying all the others. A small application with one provider and one execution path may need a clear resource owner and a few ordinary functions, with no compiler machinery at all. An application processing large populations of similar entities has a separate reason to care about component layout. Calling both applications "functional" won't tell you which work they need.
The decision is easier to defend when it names a responsibility. A class can own a browser session. A pure function can choose an allowed tool. A record can carry the tool request across that boundary. The design starts getting expensive when the browser, the policy and the request all inherit from the same idea of an Agent.

Message passing and inheritance impose different contracts
"Object-oriented" can describe several choices that deserve separate decisions. An object can protect state. A class can define how instances behave. An inheritance relationship can reuse an implementation or promise that a subtype supports a parent's contract. A message can ask another unit to do something without exposing its internal representation.
An application can combine those choices, but none of them makes the rest automatic. A class with public mutable fields gives callers direct access to its state. A method call can expose implementation assumptions even when the fields are private. An inheritance tree can contain very little useful isolation.
Hidden state needs an access rule
Alan Kay's message-oriented conception of objects emphasizes independent units that retain their own state and communicate through messages. The useful architectural question is what a caller must know to cooperate with another unit. A caller that sends a request and receives a defined response can depend on that exchange. A caller that reaches into another unit's internal state depends on how that unit was built.
Consider an illustrative browser controller. A request to open a page can leave the controller responsible for its process, tabs and cleanup. Handing every caller the raw process handle distributes that responsibility. Both designs might use classes. The class keyword tells you very little about who can break the controller's assumptions.
Messages also need meaning. A request needs a shape, a result needs a way to represent failure, and both sides need an agreement about what completion means. Naming a method send doesn't establish any of those properties. A useful message boundary lets an implementation change while the exchange remains intelligible.

Agents share some of these concerns
An agent with private context and defined communication channels resembles that message-oriented picture. The resemblance helps identify design questions. Who owns the context? Can another agent mutate it? What information leaves the agent, and what must remain private?
The resemblance also has a limit. A channel between agents doesn't establish isolation. Agents might share a filesystem, a database or a tool with authority to change another agent's work. A message protocol doesn't make a model deterministic, either. An architecture has to specify those properties separately.
The same care applies to the actor model. Message passing and state ownership make it a useful comparison, but calling an agent an actor doesn't supply supervision, delivery guarantees or a failure policy. Those are engineering obligations with concrete consequences when a worker disappears or a message arrives twice.
A design discussion becomes clearer when it names the property being defended. "This object owns its connection" is a resource argument. "This unit accepts requests without exposing its state" is an encapsulation argument. "These subclasses share an implementation" is an inheritance argument. Each can be evaluated on its own.
That leaves room to use objects while rejecting a particular inheritance hierarchy. The hierarchy has to earn its place through the changes it makes easier and the dependencies it introduces.
Composition lets capabilities vary independently
A hypothetical vehicle simulation starts with a reasonable class tree. Vehicles divide into road vehicles and aircraft. Then the requirements add electric power, autonomous control, persistence and the ability to operate as a shop. A flying electric vehicle that sells goods now crosses several branches.
The tree was organized around one classification. The requirements arrived along several independent axes. Adding another branch doesn't remove that mismatch. It forces a choice about which capability gets promoted into a parent, copied into several children or represented through another mechanism.
A tree branches along one dimension at a time, while requirements can form a matrix. The distinction explains why a tidy taxonomy can become difficult to change even when every class name still looks reasonable.
Inherited behavior brings inherited assumptions
A subclass depends on more than the name of an operation. It may depend on when a parent calls an overridable method, which fields the parent initializes first, or which sequence of operations leaves the instance valid. A change to that sequence can affect descendants without changing a public method signature.
That's the risk behind the fragile base class problem. The difficulty comes from dependencies on inherited implementation, especially assumptions that aren't part of an explicit contract. An interface can promise a capability without sharing that implementation. The distinction is easy to lose when the same language syntax supports both.
The composition principle associated with the Gang of Four's Design Patterns belongs inside this discussion. It gives an object-oriented reason to prefer assembling collaborators over inheriting more behavior. It doesn't require the conclusion that every use of inheritance is discredited. A shallow hierarchy with a stable contract can be a reasonable fit, especially when a framework already defines it and the alternatives would add more machinery.
The question is what a proposed change will require. Adding persistence to an agent should reveal which data must be saved, who saves it and how failure is reported. A base class that makes every agent persistent may hide those decisions until one agent needs a different retention policy.

Parts still need rules for working together
Composition represents the hypothetical vehicle's capabilities as parts. A power component describes the energy state. A flight component carries the information needed for flight. A shop component adds the data used to offer goods. Systems or collaborating objects use those parts without forcing every capability into a single ancestry.
The gain is independent variation. A new combination of existing capabilities can reuse their behavior without requiring a new class for every combination. That gives the requirements matrix a representation closer to its shape.
Combination alone doesn't make the design correct. Flight can consume power. A shop may need inventory. Persistence needs a stable identity and a policy for which fields survive a restart. A system has to reject an invalid combination or define how it behaves. Moving responsibilities into parts makes those relationships available to model; it leaves the modeling work with you.
This is where composition can be oversold. Replacing one large class with many little objects that all mutate one another can preserve the old coupling in a less visible form. A useful separation specifies what each part owns and which operation coordinates a change involving several parts.
For a small set of collaborators, ordinary objects and functions can express that separation. For a large population with many combinations of capabilities, the entity-component-system approach makes the separation more explicit.
ECS separates identity, data and repeated work
Entity-component-system, usually shortened to ECS, gives each of those responsibilities a different place. An entity supplies identity. Components hold data associated with that identity. Systems perform operations over entities selected by the components they have.
The separation is useful when a population contains many combinations of capabilities and the same operation runs across many members. The hypothetical flying shop can carry position, velocity, power and inventory data without needing to descend from a class that anticipated that combination.
A query selects the entities a system can process
A movement system selects entities with position and velocity. An inventory system selects entities with inventory data. The movement system can leave shop-specific fields alone, and an entity that has no velocity won't enter that movement query.
For a data engineer, the analogy is inviting: entities resemble rows, components resemble columns, and systems resemble queries. A simulation tick resembles a batch of transformations. The comparison carries the selection rule. The system operates on the data required for its job instead of asking an entity to dispatch through a large menu of possible behavior.
It remains an analogy. An ECS implementation can store components in ways that differ from a relational table, and a system that updates positions has effects on simulation state. Calling a system a function doesn't establish functional purity. The architecture separates responsibilities; the implementation determines its storage and mutation rules.
A component combination also leaves questions about ordering. Movement may need the result of a power calculation. A system may add or remove a component that another system queries. The simulation needs a rule for when those changes become visible. Otherwise composition has moved a dependency out of the class hierarchy and into scheduling, where it can still be wrong.
The expressiveness benefit survives those qualifications. A new combination of supported capabilities can be represented as data. A genuinely new capability still needs the code that gives it meaning, and some combinations need validation. The flying shop gets its shape from parts; the simulation still needs rules for keeping it in the air.
Memory layout is a separate engineering choice
The performance argument concerns which bytes a processor has to read to do the work. An update that only needs position and velocity gains nothing from loading inventory descriptions or unrelated configuration. Repeated pointer traversal can add work that a predictable traversal of adjacent data avoids.
A data-oriented layout can group the values used together. A system processing a run of positions then has a different memory-access pattern from code following scattered objects to reach one field in each. That's the useful connection to column-oriented analytics: organize stored data around the operation that consumes it.
The ECS label doesn't promise one layout. The class label doesn't require every object to be scattered. A comparison needs actual implementations, the same work and the same correctness requirements. It also needs to account for the other work the application performs, including changes to component membership and access that doesn't resemble a batch scan.

A claim of an order-of-magnitude speedup needs a matched benchmark for the workload. The design reason is enough to justify investigating layout when profiling shows that repeated data access is expensive. It can't justify replacing an unmeasured system merely because ECS has a reputation for performance.
Game-engine examples need the same discipline. A simulation can be data-oriented and composition-heavy without using a textbook ECS. A named engine, a library dependency and a developer's explanation of a specific subsystem support different claims. Claims about whether Cities: Skylines 2, Factorio or Dyson Sphere Program use ECS need developer evidence that identifies the relevant implementation.
For an agent application, ECS is most useful first as a question about capabilities and repeated operations. A browser session, a model connection and a database transaction have another property that this picture hasn't addressed yet. They have to be acquired, kept valid and released.

Objects can own resources with lifecycles
A connection pool contains live connections. A browser controller owns processes and tabs. A sandbox can hold an execution environment with permissions and running work. These resources change state as the application uses them, and leaving them around after their job ends can consume capacity or preserve access that should have ended.
An object gives those responsibilities a place to live. Initialization establishes configuration and acquires the resource. Operations use it while preserving its assumptions. Cleanup releases what the object owns. A teammate looking for who closes the browser has one owner to inspect.
Ownership makes cleanup a design question
The reason for grouping those operations is their shared lifetime. A connection can become unusable between requests. A sandbox can fail during initialization. Cleanup can fail too. The owner has to define what each failure means and which operations remain valid afterward.
C++ RAII ties resource management to object lifetime. A Python context manager provides an explicit entry and exit protocol around a block of work. These mechanisms help place cleanup where control flow will reach it, including ordinary exception paths. They give a program structure that can be inspected for ownership.
They don't make every resource problem disappear. A context manager only performs the cleanup its implementation supplies. A process started inside a sandbox may need its own termination policy. A hard termination of the supervising program is a different case from an exception unwinding through its exit method. The operating environment still matters.
The useful code-review question is therefore precise: what does this owner acquire, and which paths release it? A with Sandbox(root) as sb: block is a good place to look for that answer. The syntax alone can't prove that no child process will survive.

A class can carry data without owning a connection
There's another reason to avoid turning this into an argument against classes. In my Python work, I use Pydantic data models to give integrations a shared definition of what passes between them. I keep one place to refine the data model instead of letting every integration invent its own shape.
A typed data model and a client connection can both use class syntax while carrying different responsibilities. The data model represents a value. The client owns access to something outside the program. That difference affects copying, persistence, tests and cleanup far more than the spelling of either definition.
An SDK's existing client object can also be the sensible integration point. Using it gives a team the library's established configuration and operation names. Wrapping the client is worthwhile when the application needs a narrower contract or a stable internal meaning. Rebuilding its object model to preserve an ideological preference creates another implementation for the team to maintain.
There are costs on the other side. An object that owns a connection can accumulate policy simply because it's nearby. A model client might begin deciding retries, tool permissions and which conversation events deserve persistence. Each added decision expands what a test must arrange before it can ask whether that decision is correct.
I keep the resource owner focused on the resource. It knows how to call the provider and report the outcome. The application policy decides what that outcome means for the next action. That separation lets ordinary objects remain useful without turning them into a domain hierarchy.

A functional core decides what the shell should execute
Gary Bernhardt's functional-core, imperative-shell pattern gives that separation a name. The core works over values and returns values. The shell obtains information from the world, supplies it to the core and performs the actions the core selects.
For agent orchestration, a useful dividing line sits between choosing an operation and carrying it out. A policy can decide that an allowed tool request should run. A resource-owning adapter performs the tool call. The adapter reports what happened, including failure, and the policy decides the next step from that result.
A model response becomes an explicit input
Take an illustrative agent loop. The shell calls a model provider and receives a response. An adapter translates the response into an internal value. The core receives that value together with conversation state and the applicable policy. It can then return a revised state and a description of the work to perform.
The decision function doesn't need to contact the provider to determine whether a requested tool is allowed. It needs the request, the allowed capabilities and any other facts that the policy uses. A response containing a disallowed tool should produce a defined rejection or next action from those inputs.
The following signature shows only that separation. The types describe application data; they aren't definitions of a complete implementation.
def decide(
state: AgentState,
observation: Observation,
policy: Policy,
) -> tuple[AgentState, tuple[Command, ...]]:
...
A returned Command describes an intended effect. It might request a model call, a tool execution or persistence of an event. The shell interprets it using the relevant resource owner. It then brings the outcome back as another observation.
That extra step can look fussy beside calling the SDK from inside a method. Its value appears when a policy needs to be tested or changed. The decision has a visible input and output. A test can supply a response value and inspect the chosen command without launching a browser or authenticating with a model provider.

Purity depends on what a function does
A function that receives and calls a model client is still doing I/O. Replacing the client with a callable changes how the dependency is supplied. It leaves the effect in the same place.
The same issue appears in apparently harmless dependencies. Reading a clock makes the decision depend on when it runs. Drawing a random value makes it depend on another source of state. Looking up a permission in a database makes it depend on a service. A decision intended to be pure needs those observations supplied as explicit values.
The design doesn't require the whole application to become pure. It concentrates the rules you want to reason about into a place where their inputs can be inspected. Parsing an already-received response, constructing a prompt from supplied context and reducing an event into a new state can fit there. Fetching the context, receiving the response and writing the new state belong in effectful execution.
The separation also makes a limitation visible. A correct pure decision can still lead to an incorrect system when the shell executes the wrong command or reports the wrong result. Authentication, retries, timeouts, cancellation and transaction boundaries deserve their own care. Shell code can have consequential bugs even when its business policy is small.
Replay needs a record of what happened
Explicit inputs make a recorded decision easier to reproduce. Save the state, observation and policy version used for a decision, and you have material for asking why that command was selected. A fresh model call would be a new external operation, so reproducing a decision from a captured response is a different promise from regenerating the same response.
Events need similar precision. An intended action and a completed action are different facts. A failed tool call must remain distinguishable from a successful one. Writing a progress file can help a later session resume, but a directory of files alone doesn't establish event sourcing. A replayable state model needs defined events, ordering and rules for applying them.
Several small cores can communicate through messages, and an agent system can use that structure. The shared idea is controlled ownership across boundaries. It leaves each design responsible for its own failure and delivery semantics, rather than making actors, objects and agents interchangeable names.
Ports define contracts without requiring a class hierarchy
A functional core still has to participate in an application that calls providers, runs tools and saves results. Ports and adapters give those exchanges an explicit contract. A port states what a participant may request or supply. An adapter connects that contract to a particular implementation.
Alistair Cockburn's ports-and-adapters model provides the boundary idea. Mark Seemann's functional interpretation applies it to functions. The useful distinction is between a contract and the language construct used to express it. An interface, a protocol or a function signature can describe an exchange. Choosing one doesn't decide whether the exchange has effects.
A function shape describes only part of the agreement
Python callables can express the shapes of effectful capabilities. These illustrative aliases describe the inputs and outputs expected by orchestration code. The named request and result types stand for application-defined values.
from collections.abc import Callable
CallModel = Callable[[ModelRequest], ModelResponse]
RunTool = Callable[[ToolCall], ToolResult]
PersistEvent = Callable[[Event], None]
A function matching CallModel can wrap an SDK client. A test function can return a fixed response. Passing either function to orchestration code supplies a dependency without requiring a mock class or an inheritance tree.
The alias leaves much of the agreement unwritten. Does CallModel raise on failure or return an error value? Does it block until completion? Does retrying repeat a chargeable operation? A callable shape alone answers none of those questions. A real port needs consistent behavior as well as compatible types.
The same issue is especially visible in PersistEvent. Returning None says nothing about whether the event was durably saved. The contract must establish when successful return means persistence succeeded, how failure is reported and whether an uncertain result can be retried. A short type declaration can introduce that discussion. It can't replace it.

The composition root connects lifetimes to capabilities
A composition root is the place where the application constructs its resources and connects them to the operations that use them. It can initialize a model client, open a connection pool and arrange for both to close. Adapters then expose the capabilities the application requires.
The inward-facing function can be small. It accepts an internal request, translates it into the SDK's request shape, invokes the client and translates the outcome back. Code deciding which action should happen next can remain independent of the SDK's request classes.
That last distinction keeps the architecture consistent. Effectful orchestration uses the injected capabilities. A pure decide function, such as the one above, receives observations and returns command data. Calling an injected CallModel inside decide would move the provider effect into that function, regardless of how neat the dependency injection looks.
A collection of capabilities can be passed together when several operations need the same contract. A frozen record can keep the bindings stable. It still doesn't make a captured client immutable or its calls pure. The resource owner's lifetime must cover the time those functions can be used, and callers need a defined outcome after shutdown.
A fixed-response lambda is useful when one test only needs one successful exchange. A failure-path test may need a fake that records calls or produces a sequence of outcomes. Build the fake the contract requires. The gain is that the requirement is visible, without assuming every useful test double fits on one line.
The team can inspect where clients are constructed, which contract an adapter supports and where policy decisions run. Replacing a provider then has a named place to start. Several providers and several execution targets create a larger translation problem, which is where the compiler comparison earns its place.
A shared representation can reduce pair-specific translation
A compiler has to preserve meaning while changing how a program is represented. Source languages differ, and execution targets differ. An intermediate representation, or IR, gives frontends and backends a shared form to work with. A frontend translates into it. A backend translates out of it.
LLVM supplies the compiler comparison. The architectural attraction is the separation: a new producer can target a shared representation without teaching every consumer its source dialect. The same question appears in an agent system when several model providers produce requests that several execution environments must understand.
Adapter counts explain a possibility
Consider a constructed example with twenty source languages and fifteen target architectures. A separate translation path for every pair gives 300 paths. One frontend per language and one backend per target gives 35 adapters around a shared representation. In symbols, the comparison is M times N against M plus N.
Those are counts in a simplified design, not measurements of compiler engineering effort. They assume that the shared representation can express what each path needs. A backend can still have substantial work to do. A new frontend can introduce meaning the representation doesn't yet carry. Common optimization and verification machinery also has to be built and maintained.
The arithmetic explains why a shared representation can be attractive. It doesn't promise that every adapter costs the same or that every pair of integrations will work correctly because both compile.

For an agent application, a provider adapter can translate a model response into the application's concepts: a message, a requested tool call, a completion or an error. An execution adapter can translate an allowed internal command into a browser operation, a sandbox task or a database action. The core decides over those concepts without carrying each provider's response objects through its policies.
A new provider then has a bounded integration point. The team checks how its output maps into the internal representation and what capabilities it can support. Existing routing logic can be reused to the extent that those meanings remain the same.
The representation must preserve distinctions
Tool calls have identities. Results have to be matched to the requests that produced them. A request can be rejected, fail during execution or complete successfully. A provider can support one kind of response while another supports a different one. Flattening those distinctions into a generic string makes the common format easy to populate and hard to trust.
A useful IR therefore starts with the distinctions the application actually needs. A conversation record describes messages and their relationships. A command describes an intended operation. An observation records an outcome. The data types should make those roles clear enough that code can't casually treat an intention as evidence of completion.
The representation also needs a way to evolve. A stored event interpreted under one schema may be ambiguous under another. Replay needs to know which rules apply. An adapter that encounters an unsupported provider feature needs to reject it or expose the limitation, rather than silently discard the information and let the core proceed as though nothing were missing.

These obligations determine whether the common representation is doing useful work. Compare two actual integration paths. Find the translation they duplicate, the differences they must preserve and the policy code that currently knows vendor-specific details. Those observations justify a shared contract. A speculative IR for providers nobody uses creates its own work before it removes any.
My content compiler applies the same separation to documents
I built an early content compiler inspired by LLVM IR and the browser's DOM. The problem was long-form AI writing that became difficult to control as a whole document. The design represented content as smaller units so an editing operation could target a sentence or paragraph before the pieces were assembled into an output.
That early design represented content as atomic database nodes, with separate processing passes and output backends for Markdown, PDF and HTML. That gives a concrete use for an intermediate form. A correction can target a represented unit instead of asking for the entire document again, and an output format can be handled separately from the content operation.
The system was an MVP in its infancy. Industrial-scale personalization was the direction I wanted to take it. A production claim about provider compatibility would need separate integration evidence.
The transfer to agent orchestration is a design argument. Give policies a representation you control. Adapt external formats at defined boundaries. Preserve the evidence needed to inspect what happened. Whether that investment pays for your application depends on the integrations and changes it has to support.
An architectural position can change with the work
A design recommendation should tell a teammate what to do with a component in front of them. "Use OOP" leaves too much unresolved. "Keep the provider client responsible for its connection and move tool-permission decisions into a function over explicit inputs" names a change someone can inspect.
The positions fit together because they assign different responsibilities. They remain useful when each keeps its limits. A component model doesn't guarantee memory locality. A callable doesn't guarantee purity. A message channel doesn't guarantee isolation. An IR doesn't guarantee compatibility.
A responsibility suggests a starting design
A review can start by identifying the responsibility that a proposed abstraction would carry. These are the distinctions I'd use for the architecture described here.
| Responsibility | A useful starting point | A condition to check |
|---|---|---|
| A connection, browser or sandbox must stay valid and eventually close | An object or native resource-management mechanism with a clear owner | Initialization, failure and cleanup paths are defined |
| A routing or permission rule must produce a repeatable decision | A function over explicit state, observations and policy | Hidden I/O, time and randomness haven't entered the decision |
| Capabilities vary independently across entities | Composed parts, with ECS worth considering for repeated population-wide operations | Invalid combinations and update ordering have rules |
| Producers and consumers duplicate translation logic | A shared representation with adapters | Important differences survive translation and unsupported cases stay visible |
The table describes starting points. A framework can require a class. A small application can have too little translation work to justify a separate IR. A performance problem can sit somewhere other than the layout of entity data. Each of those findings should change the position.
One boundary can reveal the next useful change
Pick an existing operation, such as deciding whether an agent may run a requested tool. Find the inputs the rule uses. If the rule reaches through a client to fetch permissions or reads mutable conversation state from another object, those dependencies explain why testing it requires an environment.
The next useful change may be small. Supply the required observation as data, return the intended action and let the existing resource owner execute it. Keep the actual failure outcome visible when execution returns. That gives the team one boundary to inspect without requiring a rewrite of every class around it.
A later integration can justify another step. Repeated provider-specific conditionals may show that a common request form is missing. Independent capabilities may expose an inheritance choice that no longer fits. Evidence from the application decides which separation to strengthen next.
That's the portfolio position I want to hold. Objects carry resource ownership. Pure functions carry decisions whose inputs can be made explicit. Composed data carries independent capabilities, and a shared representation carries meaning across integrations that need it. The allocation can change with the work; the responsibilities still need owners.

