DEV Community

Cover image for Day 15: API Contract Layering and the Type Registry
Jeff Tsai
Jeff Tsai

Posted on Originally published at hackmd.io

Day 15: API Contract Layering and the Type Registry

The extension points from the previous post, Extension Points on a Business Object and When to Override Them, all hang on one concrete business object. The question one step earlier is this: when a call reaches the back end, how does the framework know which type to build, and what do the parameters that type receives look like?

In most projects both of those are hard coded. Type dispatch becomes a switch, and the type used for transport is handed to the business logic directly. A system with a thousand forms cannot afford either. The first means changing code and redeploying for every program added, and the second lets the client reference the business logic layer's internal properties.

This post covers:

  1. What the contract interface, the API type and the business object type are each responsible for, and which way their dependencies point
  2. ProgId as the key of a type registry
  3. Why the registry is one flat list, and why the menu is a separate definition
  4. What happens when a type cannot be resolved on either the business object or the repository axis, and why there is no fallback

1. One property list, two implementations that never meet

For the parameters of one API method, the copy that goes out and the copy the business object uses have different requirements. The copy that goes out has to be serializable and has to be referenced by the client, while the business object's copy may carry internal properties the client never sees, such as a flag passed only between business objects. Make them one type and the client has to reference the business logic assembly, while the business logic has to evolve along with the transport format.

The framework's cut is to pull the property list out into a contract interface, implemented separately on both sides:

Layer Assembly Shape Used by
The contract interfaces IXxxRequest / IXxxResponse Bee.Api.Contracts Read only properties and no serialization annotations at all Implemented separately by both sides
The API types XxxRequest / XxxResponse Bee.Api.Core Inheriting ApiRequest / ApiResponse, responsible for being serialized and sent The client and the transport layer
The business object types XxxArgs / XxxResult Bee.Business Carrying no serialization mechanism, free to add internal properties beyond the contract Business object method signatures

Taking a form save as the example, ISaveRequest, SaveRequest and SaveArgs laid out together look like this:

// Bee.Api.Contracts.Form: the property contract, the single source of truth
public interface ISaveRequest
{
    DataSet? DataSet { get; }
}

// Bee.Api.Core.Messages.Form: the copy that goes out
public class SaveRequest : ApiRequest, ISaveRequest
{
    public DataSet? DataSet { get; set; }
}

// Bee.Business.Form: the copy used in business object signatures
public class SaveArgs : BusinessArgs, ISaveRequest
{
    public DataSet? DataSet { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

The dependencies among the three layers point like this:

Bee.Api.Contracts
    ├── Bee.Api.Core ── Bee.Api.Client
    └── Bee.Business
Enter fullscreen mode Exit fullscreen mode

Bee.Api.Core and Bee.Business do not depend on each other, and each knows only the bottom layer. An Overview of Definition-Driven Architecture discussed the direction of dependencies being a check line in itself, and this is another position on the same line. The day a business object's parameter type has to reference the API assembly to compile, the layering is already broken, and adding a project reference is a perfectly legal action that the compiler has no opinion about.

The glue between the three layers is deliberately not registration. On the way in it is reflection: the framework finds the method to call on the business object and turns what arrived into the parameter type that method declares. On the way back it is a naming convention: a business object returns LoginResult, the framework finds LoginResponse from that and copies the properties across, with the resolved result cached by type so each type is resolved once.

{Action}Result  →  {Action}Response
Enter fullscreen mode Exit fullscreen mode

The cost is written into the convention itself, which is that nothing checks it. A return type whose name does not match this shape is not converted, the framework hands the business object's object straight to serialization, and the problem only surfaces the first time that method is called. What it buys is that adding an API method requires registering nothing anywhere.

Not every method needs three types either. The reason a contract interface exists is to let both sides share one property list, so a method that only travels between business objects and is not exposed needs one XxxArgs, with no contract interface and no API type. Three is the most there is.

A fourth kind of type, which never leaves the server

The SaveContext shared by the three save steps in the previous post, and the DeleteContext matching it on the delete side, are not in those three layers, and the naming is deliberately different. The test is one sentence: Args and Result for crossing layers, and Context for state shared among the stretches of one call.

XxxArgs holds what the caller sent in, and XxxResult's values get copied into the copy sent back, and neither touches serialization itself, carrying only data the client can see. XxxContext is never sent out, and holds the already resolved repository and FormSchema, the whole snapshot taken before deletion, and the output one stretch produced for the next, plenty of which are server side objects the client should not see.

Naming them apart buys two things. First, an intermediate state placed on Args is structurally one the caller can fill in, and the next stretch of code cannot tell whether that value was computed by the server or sent in by the caller. Second, a context is one object rather than a set of signatures, so giving the stretches one more shared value is adding a property, not changing a signature, and changing a signature fails compilation for every existing override at once. Extension point signatures have to stay still for a long time, so shared things cannot grow on the signature.

2. ProgId is the registry's key

A request arrives carrying a ProgId, and the framework has to exchange it for a business object type. What the registry records is that object, and whether there is a form behind it is a separate matter. This mapping can be written in code or in a definition file, and the difference is whether adding a program means redeploying.

The framework's reference model is COM+'s registration model, which registers a ProgID and its matching component type by registry key, one ProgID standing for one independent feature or program. That model has one often overlooked characteristic: the registry only handles which type a ProgID exchanges for, and has nothing whatsoever to do with where that program sits in a menu.

The framework carries that model forward, with one ProgId binding one business object, which gives three direct inferences:

  1. The framework's own SystemBusinessObject and LogBusinessObject are in there too, and neither corresponds to any form
  2. Repositories work the same way, with one ProgId binding both its business object and its repository
  3. Customization overrides types by ProgId, with the packaged product and the customization independent of each other, and the mechanism left to The Scope of the Customization Layer and Overlay Granularity

Session Management and Lifecycle mentioned every business object sharing one constructor signature, so the factory does not have to know in advance which family it is building. The reason is here: all the factory has in hand is a ProgId and a registry, and the family is the result of resolution, not its input.

The reserved ProgIds are in there too

System, AuditLog and AuditRule are also ProgIds, registered in the registry like any other program and taking the same resolution route, with the transport layer keeping no branch for them. Even SystemBusinessObject takes the same customization route, and the unit of replacement is still the ProgId.

The cost is a cliff at startup. The registry is the only source, so a missing entry cannot be resolved, and these few are the framework's own things, with no reason to require every host to hand write them into its own ProgramSettings.xml. The answer carries over the other half of COM+: a component writes its own registry entries when it is installed. At startup the framework checks the reserved words one at a time, fills in whichever are missing, and then verifies that every one of them resolves to a usable type.

One at a time rather than one file at a time, because using whether the file exists as the test would skip exactly those deployments that already have a ProgramSettings.xml which simply has no System in it. Writing back to the file only decides whether this has to be done again at the next startup, and an unregistered reserved word resolves to the framework's own type anyway, so a read only deployment that cannot write still starts.

3. The registry is flat, and the menu lives apart

The registry's premise is that a ProgId is a unique key. So this list is one flat layer, with uniqueness guaranteed by the key mechanism itself: the collection is keyed by ProgId, case insensitively, duplicates are blocked at the moment of loading, and a lookup is one key lookup. The notion of categories stays in the menu definition only, so the two cannot fall out of step.

Making the menu a separate definition is another decision, for the same reason The Purpose and Classification of Definition Files gave for DatabaseSettings and DbCategorySettings being two files:

Type registry Menu definition
Readers Only the server needs it Only the client needs it
Contents Assembly qualified type names Ordering, titles and visibility
Whether it goes out Blocked outright when definitions are fetched remotely Meant to reach the client all along
Structure One flat layer keyed by ProgId A multi level tree whose nodes have ids unique across the whole tree

Type names not reaching the client is not protection added on top, it is the natural result of the split. What the client wants is the menu, it has no use for the registry, and so the registry can be blocked from remote definition fetching alongside SystemSettings and DatabaseSettings.

The split also blocks one concrete problem. When the client builds a menu it walks every item the definition gives it, with no notion of "this program should not appear on the menu". Grow the menu straight off the registry and System and AuditLog become two menu items, and avoiding that means adding a visibility flag to the registry too, or reserving a category. Meanwhile AuditRule, also a reserved word, does belong on the menu, since it is a form people maintain. When some of one list has to appear and some must not, it is not one list.

4. One ProgId binds two types, and neither has a fallback

What the registry records is an assembly qualified type name, which is to say a string. A typo, an assembly not deployed to the host, or a type renamed without the registry being updated, all leave resolution without a type. What the framework does is always to throw, and the only difference is when:

Binding When the type fails to load or has the wrong base type When it is discovered
The BusinessObject of a reserved ProgId It throws, and it is verified once at startup as well At deployment time, with the host failing to start
The BusinessObject of an ordinary ProgId It throws The first time that program is called
The Repository of any ProgId It throws The first time that program reads or writes data

Having no fallback is not about strictness, it is about what failure looks like. Falling back to a framework default type buys nothing but the appearance of still running. FormBusinessObject's constructor accepts any ProgId and would construct successfully, so that program would quietly behave in a perfectly generic way, with not one of the custom methods Order was supposed to have present. What the caller receives is "no such method", while the actual cause is one string on one line of the registry.

The repository axis can afford a fallback even less. Get the binding wrong and that program's reads and writes switch to the generic SQL its author deliberately replaced, and the failure surfaces only once the data is already wrong. The reserved word check moves the moment of discovery forward to deployment time, since a wrong binding on System means the whole login route is dead, and rather than let a host start taking requests with a wrong binding, it is better that it does not start.

Why the repository axis cannot be fully ProgId driven

"One ProgId, one business object, one repository" holds completely on the form track and does not hold on the framework track. So the factory for obtaining a repository has two methods rather than one, and the two lines on IRepositoryFactory are these:

T CreateFormRepository<T>(Guid accessToken, string progId) where T : class, IDataFormRepository;
T Create<T>(Guid accessToken = default) where T : class;
Enter fullscreen mode Exit fullscreen mode

Two breaches force that fork open. The first is that System as a single ProgId corresponds to several repositories, since SystemBusinessObject under that one ProgId uses the session, the user and the API key tables among others, and a ProgId alone cannot decide which to return. The second is that a batch of consumers are not business objects and have none of a business object's context. The background service clearing expired sessions has no request, no session and no token, and the stretch resolving employee context runs while the session cannot yet say which company it is in, so even which database to read has to arrive as a parameter, and one method there uses two repositories. Inventing a ProgId for them would not make them any more like business objects.

None of this affects the business object axis, where one ProgId to one business object type still holds. Both methods are generic, so adding a system table needs no change to this interface, whereas the predecessor it replaced grew one more method for every table added.

Back to Northwind

The case study's registry is eleven entries, and everything in ProgramSettings.xml is here:

<ProgramSettings>
  <Items>
    <ProgramItem ProgId="System" DisplayName="System" />
    <ProgramItem ProgId="Category" DisplayName="Categories" />
    <ProgramItem ProgId="Supplier" DisplayName="Suppliers" />
    <ProgramItem ProgId="Customer" DisplayName="Customers" />
    <ProgramItem ProgId="Shipper" DisplayName="Shippers" />
    <ProgramItem ProgId="Product" DisplayName="Products" />
    <ProgramItem ProgId="Department" DisplayName="Departments" />
    <ProgramItem ProgId="Employee" DisplayName="Employees" />
    <ProgramItem ProgId="Order" DisplayName="Orders" BusinessObject="Bee.Northwind.Server.BusinessObjects.OrderBO, Bee.Northwind.Server" Repository="Bee.Northwind.Server.Repositories.OrderRepository, Bee.Northwind.Server" />
    <ProgramItem ProgId="AuditLog" DisplayName="AuditLog" BusinessObject="Bee.Business.AuditLog.LogBusinessObject, Bee.Business" />
    <ProgramItem ProgId="AuditRule" DisplayName="Audit Rules" />
  </Items>
</ProgramSettings>
Enter fullscreen mode Exit fullscreen mode

Of the eight forms only the order fills in both columns, and the other seven leave both empty, so all of them land on the framework's default FormBusinessObject and DataFormRepository. The Northwind Case Study: Eight Forms and a Custom Business Object said only one needs the application to take over, and what corresponds to that in this file is the seven entries with nothing filled in. The line from the previous post that obtains the order's repository gets its type from exactly that Repository column.

The three reserved words are on this list too. System and AuditRule fill in no type and each resolve to the framework's own, while AuditLog writes that name out. Bringing the reserved words into the registry buys exactly this: taking over one of them means filling in that BusinessObject column.

In closing

A type registry has one question to answer: which type handles this call.

  • The contract interface decides what the properties are, while the API type and the business object type each decide how they are sent out and how the business logic uses them, and the two never meet
  • ProgId is that key, so when a call arrives the type is decided by the registry, not by a branch in the code
  • Flattening it to one layer is what makes uniqueness a matter of structure, rather than of somebody remembering not to write the same ProgId twice

The test worth taking away: what a type registry really has to design is not what happens when a type is found, it is what failure looks like when one is not. All three bindings give the same answer, because what a fallback buys is nothing but the appearance of still running. The only difference is when it is discovered, at deployment time for the reserved words and at the moment of first use for the rest.

The next post, JSON-RPC 2.0: Protocol Choice and the Request Pipeline, is about the protocol these calls arrive over, and why it is JSON-RPC rather than REST.


📚 Part of the thirty-post series ERP Architect's Notes: Designing a Definition-Driven Framework

📘 Original article on HackMD, in Traditional Chinese:
👉 https://hackmd.io/@jeff377/day-15-progid-registry

📢 You are welcome to republish this, please credit the source.
📬 Follow for more engineering notes:
Facebook | HackMD | GitHub | NuGet

Top comments (1)

Collapse
 
fgrandon profile image
Francisco Grandón •

Splitting ISaveRequest into SaveRequest for transport and SaveArgs for business logic keeps serialization concerns out of Bee.Business while allowing internal properties to travel cleanly between domain objects. Using the {Action}Result to {Action}Response naming convention avoids boilerplate registration across a thousand forms. Since a mismatched return type name only fails at runtime when serialized, have you considered a fast reflection test in CI to verify every business method's return type has a matching response class?