Lessons from a legacy authorization platform migration
At PayFit, my team is migrating a legacy permissions system toward a centralized Authorization-as-a-Service platform.
The new platform uses Oso as its underlying authorization engine. Authorization-relevant data such as roles, attributes, and relationships is represented as facts. These facts are evaluated against policies written in Polar, Oso’s declarative policy language, to answer questions such as: “Can this user perform this action on this resource?”
One phase of this migration concerns what our legacy system calls “workflows.”
The name is misleading. In software, a workflow usually describes a sequence of activities or state transitions. Our workflows do not do that. They are records used to represent relationships, visibility permissions, and validation rules like allowing someone to approve another employee’s absence requests or defining which employees a user can see in a calendar.
Calling all these things “workflows” obscures their actual purpose. Removing that term is one of the improvements this migration will eventually allow us to make.
Before moving these capabilities to Oso, however, we decided to change something in the legacy system itself: consumers must stop manipulating workflows through a generic CRUD API and instead use contracts organized around explicit business capabilities.
At first, spending time redesigning a legacy API that we intend to retire might seem counterintuitive. Why not reproduce its existing endpoints in the new platform and migrate the consumers?
Because doing so would preserve one of the legacy system’s central design problems: its apparent simplicity comes from transferring complexity to every team that consumes it.
The illusion of generic simplicity
CRUD stands for Create, Read, Update, and Delete.
A generic CRUD API often looks like the simplest possible service boundary. Expose a resource, provide those four operations, and let consumers build whatever they need.
This design is attractive because it is uniform, reusable, and apparently flexible. The providing team does not need to understand every consumer’s use case. Consumers can manipulate the exposed resources however they want.
Our legacy permissions system followed this approach for workflows:
- Create a workflow.
- Retrieve workflows.
- Update a workflow.
- Delete a workflow.
From the provider’s perspective, the API is simple. It supports many behaviors without requiring a dedicated contract for each one.
Consumers, however, rarely think in terms of manipulating workflows. Their actual needs sound more like:
- Establish or remove a manager–managee relationship.
- Delegate absence approval to another person.
- Configure who can see an employee’s calendar.
- Determine whether validation rules exist for a company.
To implement one of these capabilities through the CRUD API, a consumer must first understand what a workflow represents in the permissions system. It must know which fields and workflow types correspond to its intent, how to find existing records, which records to remove or update, and in which order those operations must happen.
The interface is technically simple but semantically demanding.
Genericity can therefore produce a false sense of simplicity. A small API surface does not necessarily mean that the overall system is less complex. It may only mean that the complexity is no longer visible from the provider’s side.
Where the complexity actually goes
Suppose a domain team wants to allow one employee to validate another employee’s leave requests.
With a generic workflow API, that team may need to:
- Retrieve the existing workflows.
- Filter them using knowledge of the permissions system’s internal model.
- Determine which records represent the current validation relationship.
- Delete or update conflicting records.
- Construct a new workflow with the correct internal attributes.
- Handle partial failures and retry the sequence safely.
The domain team has effectively become the orchestration layer for a platform capability.
This has several consequences.
First, each implementation can interpret the workflow model differently or enforce only some of its invariants.
Second, business behavior becomes distributed. The permissions system can see that a workflow was created or removed, but it cannot reliably identify why. The business intention exists only in the calling application.
Third, correctness becomes harder to guarantee. What should have been one atomic business operation is expressed as a sequence of independent technical operations.
Finally, consumers become responsible for concepts that should have remained private to the platform.
CRUD did not remove any of this complexity. It exported it.
The hidden cost of coupling
When a consumer manipulates a platform’s internal resources directly, it becomes coupled to more than an HTTP contract. It becomes coupled to the platform’s model and vocabulary.
In our case, domain teams must understand and manipulate a concept called Workflow, even though it is neither meaningful in their own domains nor an accurate description of what the platform stores. Their code becomes polluted with types, conditions, and terminology imported from the permissions system.
This creates semantic coupling.
It also amplifies change. If the platform changes the structure or meaning of a workflow, every consumer that has reproduced its orchestration logic may need to change. An implementation detail that should have been encapsulated behind the platform boundary has propagated throughout the organization.
The alternative is to make the interaction explicit:
delegateAbsenceApproval(...)setManagerRelationship(...)configureCalendarVisibility(...)hasValidationRules(...)
These contracts describe why the consumer is calling the platform, not how the platform currently implements the operation.
Behind such a contract, the legacy permissions system may continue to use workflows temporarily. The new platform may instead use Oso facts and Polar policies. That distinction no longer matters to the consumer.
As long as the business contract remains stable, the implementation can evolve independently.
A Context Mapping perspective
Domain-Driven Design’s Context Mapping patterns provide useful language for examining this relationship.
Our permissions platform is upstream: its contracts and model affect the downstream domain teams consuming its services. An Open Host Service provides a protocol through which multiple consumers can interact with that upstream context. Downstream teams may then adopt the upstream model as Conformists or protect their own model through an Anti-Corruption Layer. These relationships are described in the DDD Crew’s Context Mapping material.
If a domain team takes the conformist approach, the workflow model spreads directly into its codebase.
If it creates an Anti-Corruption Layer, its domain model remains protected, but the team must build and maintain translation and orchestration code around the platform.
Neither outcome is particularly satisfying when the translation exists only because the platform has exposed its implementation model.
This does not mean that an Open Host Service is inherently problematic. A use-case-oriented platform API may still be an Open Host Service. The important question is which language and abstractions that service publishes.
Likewise, an Anti-Corruption Layer is valuable when two genuine domain models need to remain independent. It should not become the default compensation mechanism for a platform that transfers its implementation complexity to every consumer.
The platform should publish contracts representing stable capabilities. Domain teams should remain responsible for their own business concerns—not for decoding the platform’s internals.
Technical domains still need domain modelling
This work also resonates with a discussion I had with Gregor Hohpe during ComoCamp 26 about modelling technical domains.
As someone working in a platform team and as a long-time Domain-Driven Design enthusiast, I was particularly interested in one question:
Are technical domains fundamentally different from business domains when it comes to modelling?
Our conclusion was: probably not.
Authorization may initially appear to be a purely technical concern, but it still has its own language, rules, relationships, boundaries, and evolving concepts. It therefore deserves the same modelling attention we would give to a more visibly business-oriented domain.
Treating a technical platform does not remove the need for modelling.
In our case, the generic workflow representation became the public model because we had not explicitly designed the capabilities and language exposed at the platform boundary.
The misleading name “workflow” is itself evidence of this problem. One technical abstraction gradually came to represent manager relationships, approval delegations, visibility settings, and validation rules which are concepts with different meanings and lifecycles.
The model did not disappear because the API was generic. It became ambiguous and leaked into every consuming context.
Moving toward use-case-oriented contracts is therefore not only an API redesign. It is also an exercise in modelling a technical domain: identifying its real capabilities, choosing language that reflects their meaning, and deciding which concepts should cross the boundary.
The generic CRUD API was not model-free. It exposed an accidental model.
A platform should reduce cognitive load
A platform team is not merely a team that owns shared services. Its value comes from making difficult capabilities easier and safer for other teams to consume.
Reducing the cognitive load of product and domain teams is a central goal of the platform approach described by Team Topologies. Domain teams should be able to focus on the problems that differentiate their products. They should not need deep expertise in authorization storage, policy representation, or legacy workflow semantics.
If every consumer must understand the platform’s data structures, reconstruct its invariants, and orchestrate low-level operations, the platform is not removing cognitive load. It is distributing that load across the organization.
Avoiding this requires collaboration.
The platform team must talk to its consumers, understand what they are trying to accomplish, and identify the stable capabilities hidden behind their current technical integrations.
That discovery work is not overhead. It is how the platform learns what product it needs to provide.
Once an interaction is understood, the upstream and downstream teams can agree on a contract before the implementation exists. The downstream team can develop against that contract, write tests, and provide feedback while the platform team implements the capability.
The result is a stronger boundary and a better developer experience.
Our goal should be to make using the platform a no-brainer where consumers express the outcome they need, and the platform takes responsibility for the specialized knowledge required to produce it safely.
Making the implicit explicit
“Make the implicit explicit” is a useful principle here.
With generic CRUD operations, the business use case is implicit. It exists only inside the consumer’s orchestration code, conventions, and assumptions. The platform sees mutations but not intentions.
A use-case-oriented contract gives that intention a name.
This improves more than readability. It gives the platform an inventory of the capabilities it actually provides and makes it possible to answer questions such as:
- Who uses this capability?
- What business outcome does it support?
- Is that use still legitimate?
- Are different consumers solving the same problem differently?
- Which behavior and invariants must the platform preserve?
- How frequently is the capability used?
- Can it be migrated or retired independently?
The answers may reveal that some teams are using the platform for the wrong reasons, that a capability could be provided differently, or that an existing integration no longer needs to exist.
Observability also becomes more meaningful.
“Workflow created” provides little information about the user journey involved. “Calendar visibility configured” or “approval delegation established” tells us what happened and why.
An explicit operation makes it possible to measure the usage, reliability, and migration status of an actual capability rather than infer intent from low-level data mutations.
Why this changes the migration strategy
This shift is particularly valuable during a legacy migration.
Migrating generic CRUD endpoints encourages a technical, one-to-one translation: determine how workflows should be represented in Oso and reproduce the same create, read, update, and delete operations.
But Oso does not need to know what a legacy workflow is.
It needs authorization facts representing real relationships and attributes, along with Polar policies defining how those facts produce authorization decisions. For example, a manager relationship might become a fact, while the permission to approve an absence is derived through a policy.
If we migrated the CRUD contract directly, we would risk introducing the accidental abstractions of our legacy system into the new platform. We would change the technology without improving the boundary.
By first replacing generic integrations with use-case-oriented contracts, we can migrate capability by capability:
- Identify the consumer and its actual intent.
- Determine whether the use case is still valid.
- Replace consumer-side orchestration with an explicit platform operation.
- Preserve that contract while changing its implementation.
- Migrate the capability independently to Oso.
- Remove the corresponding legacy workflow behavior once no consumers depend on it.
Each migration becomes independently deployable and observable.
Not every existing interaction necessarily deserves a direct equivalent. Some may be obsolete. Others may represent workarounds or responsibilities that belong elsewhere.
This is why changing the legacy API before migrating it is worthwhile. It creates a stable boundary between consumers and the implementation. Once that boundary exists, the legacy system and Oso can coexist behind it during the transition.
We can initially execute an operation on the legacy system, later execute it against Oso, and compare both results before switching the source of truth. Consumers do not need to participate in that internal transition.
Understanding why consumers interact with authorization also helps us design better facts and policies. We can model Oso around real, durable authorization concepts instead of reproducing accidental structures inherited from the legacy system.
In that sense, the API redesign is not just preparation for the migration, it is part of the domain discovery required to make the migration successful.
CRUD is not the enemy
CRUD is not intrinsically bad.
Some capabilities genuinely are resource-oriented. An administration interface for explicit validation rules may legitimately need to list, create, edit, and delete those rules. In that situation, the lifecycle of the rule is itself part of the user’s intent.
The mistake is assuming that every interaction should be exposed that way simply because CRUD is generic and familiar.
Nor should use-case orientation mean creating an endpoint for every button or screen. A good contract represents a stable capability, not a particular user-interface gesture. Finding the correct boundary requires judgment and collaboration.
The useful question is therefore not:
Can this use case be implemented with CRUD?
Almost anything can.
The better questions are:
Which team should own the complexity required to implement this outcome?
And:
What does the consumer actually need to know?
For a platform, the answer should usually be that consumers express their intent while the platform owns the specialized knowledge, representation, and orchestration behind it.
A simple platform interface is valuable. But it is only truly simple when it makes the entire system easier, not when it makes the provider’s side look smaller.