Ask someone who does not write software to describe what happens when they edit an article and press save. You will get something like:
- find the article
- change the title
- tell me it worked
Steps each finishing before the next begins.
Now open the code that does it:
- A controller receives a request DTO, validates it, and calls down
- A service asks a repository for the article, mutates it, saves it, and returns it
- The original controller then maps the entity to a response DTO and returns that
The steps are still in there. But they happen going down and up the layers. Nothing about the file layout tells you the order things happen in. The person who described the feature would not recognise the code that implements it.
The usual explanation is that real systems are more complicated than the business description and layers are how we manage the complication. I have stopped believing that. The business steps were a good description. What the technology lost was the shape and the shape was taken from us by the method call.
Here is the same feature as a function orchestration:
load:
class: com.example.LoadArticle
next: apply
apply:
class: com.example.ApplyArticle
next: respond
respond:
class: com.example.RespondWithArticle
Find the article. Change the title. Tell me it worked. Steps in the order they happen, each a small class you can read on its own. That is the whole of it. There is no controller resuming, no service in the middle, and nothing happening on the way back up.
The interesting part is not that it matches the business steps. It is what stops being necessary once the method round trip is gone and particularly that layers stop being necessary. The transactional proxy stops being necessary, as the transaction can span the steps rather than a method call. Furthermore, aspects stop being necessary. What replaces them can start further functions rather than "magically" injecting functionality between method calls. Unchecked exceptions stop being necessary, because nothing has to climb a call hierarchy to be handled. Each of these I see was a workaround for a constraint that the method call imposed. And with function orchestration, each of them dissolves (see Inversion of Coupling Control for a more in depth discussion of coupling by the method).
The rest of this post discusses functional orchestration in more detail.
The layered sandwich is the shape of a method return
A method call is a round trip. The caller prepares, calls the method and then does further processing with the return. Effectively a sandwich around the method call. Stack enough sandwiches and you have a layered architecture.
We were taught to call this separation of concerns (presentation, service, repository). But which way round is it? Did we choose the principle and the method call happened to serve it? Or did we work around the method call and call it a principle after the fact?
What the business describes is a pipeline
Function orchestration removes the round trip. Functions run once and hand off forward. There is no way back up. The sandwich disappears and with it the reason for the layers.
In OfficeFloor a function has one of the following roles:
- producer obtains an entity and sets it in scope
- action changes an entity
- responder turns an entity into a response
Find the article (producer). Change the title (action). Tell me it worked (responder).
The non-technical description was already a pipeline. It always was. We just did not have the first class architecture capabilities to represent it. Well, until function orchestration.
The content I showed at the top was simplified. Here is the real update endpoint with the validation and the transaction that I left out:
validate:
class: com.example.ValidateArticle
next: load
load:
class: com.example.LoadArticle
govern: [ transaction ]
next: apply
apply:
class: com.example.ApplyArticle
govern: [ transaction ]
next: respond
respond:
class: com.example.RespondWithArticle
This is a sequence of steps. It reads top to bottom in the order it happens. It requires no layers.
Note what happened to the transaction. It is not an aspect wrapping a method call. It is govern, declared on each function spanning the transaction. This deserves more than a note and I will come back to it.
No longer is the response handled as part of the method sandwich. RespondWithArticle is a function like any other. It is not a controller resuming after its service bean returns. That distinction sounds pedantic until you notice it frees the pipeline from the synchronous nature of the method call.
Variables give a function explicit inputs and outputs
If functions do not call each other, and nothing returns a value up a stack, how does the entity get from LoadArticle to ApplyArticle?
Through a variable. A producer declares Out<T> and calls set. A later function declares @Val T and receives it. OfficeFloor matches them by type.
public class LoadArticle {
public void service(@PathVariable(name = "id") Long id,
ArticleRepository repository,
Out<Article> loaded) throws NotFoundException {
Article article = repository.findById(id)
.orElseThrow(() -> new NotFoundException(id));
loaded.set(article);
}
}
public class ApplyArticle {
public void service(@Val Article article, @Val ArticleRequest request) {
article.setTitle(request.getTitle());
article.setContent(request.getContent());
}
}
Neither class references the other. ApplyArticle does not know that an article is loaded from a repository or that one was loaded at all. It just requires that an Article and an ArticleRequest are in scope and it says so in its signature.
This is the part that matters most. Consider that a single method gives its statements a local scope. Set a value on line three of the method and every line after it can use that value. State accumulates as the method runs. It is enormously convenient and likely why we keep writing long methods even though we know better.
Now try to split that method into separate "purist" functions (multiple parameters with single return type). Each function sees only its own parameters. With a single return value, a function hands exactly one value to the next one. So to carry several accumulated values forward you bundle them into a type. Then the flow grows and you need another combination, so you write another type. These carrier types model nothing in the domain. They exist to move state between functions. Anyone who has decomposed a large service method has met them, and has quietly given up somewhere around the third one.
Variables restore the shared scope. The set of live variables is the scope and any later function reads any earlier value it needs.
But it also improves on the single method and this is the real point. Inside a method a block of code uses the surrounding variables implicitly. Nothing states what that block reads or what it leaves behind. You cannot lift the block out and use it somewhere else because the set of variables it uses is implicit. They are inferred and the block of code has no interface.
A function in function orchestration declares them:
ValidateArticletransforms the post body into anArticleRequestand sets it in scopeLoadArticlereads a path variable and sets the loadedArticleentity in scopeApplyArticlereads anArticleand anArticleRequestfrom scope to take action of applying changes of the request onto the ArticleRespondWithArticlereads anArticleand transforms it into a DTO for sending in the HTTP Response
Those signatures are the contract. So the block becomes reusable wherever those variables are in scope. Furthermore, reuse follows the producer/action/responder pattern almost mechanically. LoadArticle serves the GET, the PUT and the DELETE. ApplyArticle applies the request body for the POST and the PUT. RespondWithArticle is shared by every endpoint that returns the Article. Each is written once and the YAML says where it appears.
That is the trade being made. A method gives you implicit state and no reuse. Variables give you the same shared scope, except the inputs and outputs are made explicit, with reuse as the consequence.
Governance is not an aspect by another name
Now back to the transaction because if the function orchestration is going to replace the layers then something has to replace what the layers were quietly doing for us.
In a layered application the transaction is a proxy. You annotate a service method with @Transactional. Spring wraps the bean and the proxy begins a transaction before the method and commits after it. The demarcation is the method boundary for no reason other than that the method boundary is the only thing a proxy can see.
Everyone who has used this has been bitten by what follows from it. The annotation only works if the call arrives through the proxy. Calling the method from another method of the same class silently skips the transaction. Furthermore, the unit of work must be a method. So when a unit of work is genuinely multiple steps you either fuse them into one method to get one transaction or you accept multiple transactions. The architecture bends around the demarcation mechanism.
Governance in OfficeFloor is declared as a YAML file in officefloor/govern/ and applied to functions by name:
# officefloor/govern/audit.yml
governance:
class: com.example.AuditGovernance
The following governance class states the three moments of the lifecycle:
public class AuditGovernance {
private Auditable enrolled;
@Govern // before the function body, once per enrolled object
public void govern(Auditable auditable) {
this.enrolled = auditable;
auditable.recordEvent("governance-begin");
}
@Enforce // after the governed functions complete successfully
public void enforce() {
enrolled.recordEvent("governance-commit");
}
@Disregard // when a governed function throws
public void disregard() {
enrolled.recordEvent("governance-rollback");
}
}
Begin, commit, rollback. The same three controls of a transaction. Transactions are just governance.
The differences are worth drawing out and none of them are cosmetic.
What it governs is chosen by type, not by pointcut. The parameter of the @Govern method declares an extension interface. Any managed object in a governed function that implements Auditable is enrolled automatically. Eligibility is worked out when OfficeFloor compiles the application graph, not when a weaver matches a string at class-load time. There is no execution(* com.example.service.*.*(..)) to get subtly wrong.
What it wraps is a run of functions, not a method. Look again at the PUT pipeline: govern: [ transaction ] appears on load and apply. Those functions run inside one transaction. Nothing had to be fused into one method to make that true. Nothing self invokes because self invocation is not a concept in a pipeline. Governance spans the appropriate functions because the runtime, not a proxy, is holding the lifecycle.
It is applied where the pipeline is described. The service class carries no annotation. To find out whether a function is transactional you read the endpoint file. You do not read the class, then check whether it is a bean, then check whether the caller went through the proxy, then find the pointcut, then work out whether it matches.
Administration does not have to fit through a method signature
Governance handles lifecycles that begin and end. For work that happens simply before or after a function, OfficeFloor has administration.
This is where the comparison with aspects gets uncomfortable for aspects.
An aspect is shaped by the method it advises:
- it receives untyped, positional arguments (Object[] args) which are meaningful only if the aspect knows the signature it is advising. Furthermore, if the advice needs the entity, the entity has to be a parameter of that method.
- it must call
proceed()so it sits on the calling thread inside that stack frame. Anything it wants to do has to be done inline and synchronously.
An administration is not shaped by any signature. It is attached before or after a function and it declares the extension interface it works with as the type of an array parameter:
public class AuditAdministration {
@FlowInterface
public interface Flows {
void writeAuditRecord(AuditEntry entry, FlowCallback callback);
}
public void administer(Auditable[] auditables, Flows flows) {
for (Auditable auditable : auditables) {
flows.writeAuditRecord(auditable.toEntry(), null);
}
}
}
Read what it receives. Not the function's arguments but every object in scope that implements Auditable. The administration and the function it administers share no signature and no knowledge of each other. Neither has been distorted to accommodate the other.
Furthermore, read what it can do. Flows invokes further functions. This further function orchestration can have dependency injection, run asynchronously and escalate checked exceptions. There is no method call to return, so function orchestration can add further steps to the pipeline.
There is one more thing an administration can take as a parameter and it is the one that shows how far this is from advice, a GovernanceManager. Administration can activate, enforce and disregard governance. A post-administration can commit the transaction and start a new one mid-pipeline, as a first class configured thing. In the AOP world that is the territory where you stop writing aspects and start reading the transaction manager's source code.
Administration can also read the annotations on the function it administers. The OfficeFloor Spring Boot starter implements @PreAuthorize via administration.
So the ranking is not close. An aspect gets untyped arguments, on the calling thread, inside one method, wired by a pointcut expression that is checked when the weaver runs. An administration gets typed extensions, can start further functions, can drive governance, can read the function's annotations. The first is a clever way to smuggle behaviour into a method call. The second does not need to smuggle anything because the pipeline was never a method call to begin with.
Which is why you can reason about it
Here is the practical test I keep coming back to. Take a function and ask what happens around it.
In the function orchestration you open the endpoint YAML. The functions are there by name with necessary govern. That is the answer complete in one file.
In the layered application you open the service class. You find @Transactional, so you check the class is a Spring bean and that nobody calls this method from inside the class. You find @PreAuthorize, so you work out where it sits in the proxy chain relative to the transaction, which is determined by advisor order, which is somewhere else. You search for pointcuts that might match the method's signature and hope you get it right in what methods they apply to. Then you find the annotation is on the interface or the parent class, and start again.
Both applications have cross cutting behaviour. In one it is declared where the functions are orchestrated. In the other it is inferred from annotations, proxies and pattern matching against method signatures, all of which are invisible at the point where they take effect. We got very good at the second. We should not mistake being good at it for it being good.
Exceptions do not have to climb
Now consider exceptions that are thrown at the bottom and caught somewhere on the way up. It is the clearest case of all because the language itself was bent to accommodate it.
In a layered application an exception is handled by @ControllerAdvice, which sits above the controller. For the advice to see the exception, the exception must survive the journey. It is thrown in the repository, passes through the service, passes through the controller, and only then reaches the handler.
Now recall what a checked exception requires. Every method it passes through must declare it. So a checked exception thrown at the bottom writes itself into the signature of every method between there and the top. Change what the repository can throw and you edit the service and the controller too. The signature of a method that does not care about the failure now names the failure.
This is where the unchecked exception came from. I believe not from a considered view about which failures are recoverable. It came from the cost of transit. Unchecked exceptions travel up the call hierarchy for free, declaring nothing, and once you are handling errors above the controller you need them to travel a long way. Java's checked exceptions are hard to live with in a layered application because the layers are exactly the thing that makes them expensive. So the industry gave up on them and gave up with them the one place the compiler could tell you what a piece of code can fail with.
A function orchestration has no call hierarchy so there is nothing to climb. A function throws, the orchestration short circuits, and the runtime routes the exception to a handler registered for that type:
# officefloor/escalation/com.example.NotFoundException.yml
handle:
class: com.example.NotFoundExceptionHandler
public class NotFoundExceptionHandler {
public void handle(@Parameter NotFoundException ex,
ObjectResponse<ResponseEntity<Void>> response) {
response.send(ResponseEntity.notFound().build());
}
}
Look at what LoadArticle declared earlier in this post: throws NotFoundException, and NotFoundException extends Exception. It is checked. It costs nothing because there is no function between the throw and the handler. The handler is not above LoadArticle in anything. It is registered against a type and reached directly.
So checked exceptions become affordable again and the thing we traded away comes back. The signature of LoadArticle states that loading an article can fail because the article is not there. The compiler knows. A reader of the one function knows without reading the handler.
The handler is itself a function, which is the part I would not want anyone to miss. It takes the exception as a parameter, it has its dependencies injected, and it can invoke further functions. So handling a failure is not a matter of translating it into a response and getting out. It short circuits the orchestration that was running and it starts another one. An orchestration that ends in a failure hands over to another orchestration that deals with failures.
Which is why OfficeFloor calls this escalation rather than propagation. Propagation is what a stack does with the exception rising through method calls. Escalation is what an organisation does. The case leaves the process it was in and goes to the person whose job is that case. No business describes a failed credit check by saying it bubbles up through the department hierarchy until someone catches it. They say the application goes to the exceptions team, who have their own process, their own systems and their own authority to act. That is a handler with dependencies that starts its own function orchestration.
Every business process I have ever seen documented has a happy path and a set of exception paths that branch off it. Not a happy path plus a mechanism for unwinding it. The exception path is another business process joined at the point where things go wrong. That is what an escalation handler is. It is another case of the code being able to take the shape the business description already had.
The layer boundary becomes a data boundary
A little of the old layered architecture shape survives and only at the edges. The front function takes the request DTO. The back function produces the response DTO. That is the presentation boundary and it is now the two ends of a pipeline rather than a layer that wraps the others. The middle functions work with the entity, which resembles the service layer. Except that it is the body of the pipeline and not something that is called and returned from.
There is no data layer at all. A repository is a dependency of a function injected like anything else.
So the boundary that used to be enforced by a call is now enforced by data. Entities live in the middle. Each function declares what it works with and that declaration is the boundary. ApplyArticle alters an entity and cannot see a status code or a JSON body. This is not because a layer forbids it but because those things are not in its signature.
What you give up
I would rather say this than have it discovered.
The wiring moves out of the code and into the YAML. You cannot find the next step by clicking through in an IDE. That is a real loss and the endpoint file is the compensation. One file, one endpoint, the whole sequence visible at once.
Variables are matched by type. So two variables of the same type in one pipeline need qualifiers to tell them apart. Plus there is no single stack trace through a request because there is no single stack.
These are the costs of not having a call stack. They are the same costs, incidentally, that everyone pays as soon as they go asynchronous, or reactive, or event-driven. Function orchestration is honest about paying them up front in exchange for the shape closer to the business process itself.
Closer to how people already think
The claim I want to make is not that function orchestration is faster or more scalable. Though the absence of a blocking round trip has something to say about both. It is that the function orchestration is what the person describing the feature said in the first place.
Find the article. Change the title. Tell me it worked. Nobody has ever described an endpoint by saying that the presentation layer will map the request and then delegate to the service layer, which will begin a transaction before consulting the data layer, and that the response will be assembled as the calls unwind. That description is not more precise than the first one. It is a description of the call stack wearing the clothes of an architecture.
We spent a long time teaching ourselves to think in layers. We taught it to everyone who came after and it worked well enough that the origin was forgotten. Layers are what a method call leaves behind. Take the round trip away and they do not need replacing because the thing they were organising is gone. What is left is a sequence of functions, with the state each function reads and writes written on its face.
The full pattern is written up in the Orchestration Patterns and Naming reference, the mechanics of passing state between functions in the Variable tutorial, and the begin/commit/rollback lifecycle in the Governance tutorial. They run on Spring Boot so the comparison can be made on the same ground rather than against a strawman.
As always I would be glad to hear from anyone who has tried to decompose a service method into reusable pieces and come against the limitations of the method.
No comments:
Post a Comment