The previous post, API Contract Layering and the Type Registry, was about how the framework knows which type to build once a call arrives. The question one step earlier is what that call itself looks like. A system with a thousand forms has thousands of methods to expose, and the vast majority of them are the same set of actions applied to different forms. What the protocol has to decide is how they are addressed, how parameters travel, and how failure is reported.
The framework's answer is one POST endpoint, one method string, and one pipeline in a fixed order. Each of those three decisions has a cost, and two positions on that pipeline are deliberately placed.
This post covers:
- Why JSON-RPC rather than REST, and what that choice buys and gives up
- What each of the two payload layers holds, and why every exposed method takes exactly one parameter
- What one Ping round trip actually sends, and the four things the framework does not do the way JSON-RPC 2.0 specifies
- How the method string is cut into a
ProgIdand an action, and how each half is resolved - The fixed stops one request passes through, and the one line that decides what failure looks like
1. Why JSON-RPC, not REST
This is a choice, not a verdict. Both REST and RPC have systems that have been running for over a decade behind them, and this post is not ranking them, only setting out which way this framework's mechanical needs push it.
The number of actions does not grow with the number of forms, while the number of resources does. On FormBusinessObject, the form axis has six actions, all of them declared as constants:
| Constant | Purpose |
|---|---|
GetList |
List queries |
GetLookup |
Candidate row queries for relations across tables |
GetNewData |
Fetching a blank skeleton |
GetData |
Loading one row by its row identifier |
Save |
Writing by each row's state |
Delete |
Deleting by row identifier |
Eight forms get these six, and a thousand forms still get these six. The only half that varies is the ProgId, and the ProgId is already the key of the type registry. A method is those two halves joined, so adding a form opens no new route.
There is also a batch of actions that are not state transitions of a resource. The actions under the reserved word System are not CRUD shaped: Login, Logout, EnterCompany, LeaveCompany, Ping and GetDefine are all in there. Mapping "enter a particular company" onto the creation or update of some resource means inventing a resource first. It can be invented, but that resource exists in order to fit the protocol, not because the system had it all along.
The whole body is one JSON-RPC payload. A request's params and a response's result are each one field, and what goes inside them, in what encoding, has nothing to do with the method itself. That is the premise for a transport format being replaceable wholesale without any method's signature having to know, which The Division of Labor Between XML, JSON and MessagePack takes apart. REST spreads meaning across four places, being the path, the verb, the status code and the body, and only the last of those can be swapped out.
There are two costs:
- Everything the HTTP layer can do goes unused. Caching, gateway rules and rate limiting all decide by path and verb, and here there is one path and one verb, so telling which method a call is means opening the body.
- Requests are hard to tell apart. In a browser's developer tools every entry is
POST /api, and the list alone does not distinguish them. The framework's remedy is to send the method back in the response, a field the specification does not have, which the third section comes back to.
The test is not which protocol is better, it is whether this system's outward unit is a resource or a procedure. A system whose unit is the form and whose interface is one fixed set of actions leans toward RPC, while a system whose unit is the document or the entity, and which wants the benefits of HTTP intermediaries, leans toward REST.
2. Two payload layers, and one parameter per method
Once the protocol is settled, what actually has to be decided is what goes in that payload. It has two layers, the outer one following the JSON-RPC 2.0 specification and the inner one added by this framework.
The outer layer has four fields:
| Field | Type | Contents |
|---|---|---|
jsonrpc |
String | Always 2.0
|
method |
String | ProgId.action |
params |
Object | One API payload holding this call's parameters |
id |
String | Chosen by the caller, sent back unchanged in the response |
params is the field furthest from the specification. The specification allows two shapes there, an array for positional parameters and an object for named ones. The framework uses neither, and always puts one API payload in that slot:
| Field | Type | Contents |
|---|---|---|
format |
Integer | Which encoding value uses, with 0 meaning no encoding |
value |
Anything | The parameter itself, the Request object the caller sent, which the server turns into the Args the method declares |
type |
String | The type name of value, and whether it has to be filled in is decided by format
|
The encoding half is left to the later post on the three formats, and only one thing about it matters here, which is that format and type sit on the API payload rather than inside value.
This is not working around the specification, it is that the choice between named and positional does not exist here. Every exposed method takes exactly one parameter:
public virtual PingResult Ping(PingArgs args)
public virtual GetListResult GetList(GetListArgs args)
public virtual SaveResult Save(SaveArgs args)
The dispatch stretch calls with a single element argument array, so one parameter object per method is a hard requirement rather than a convention. What it buys is that adding an input field means adding a property to the Args type. The signature does not move and existing overrides do not have to be recompiled, which is the compile time side of it.
At run time it buys something else. Over an ERP system's service life fields like this keep growing, and a caller that has not been upgraded can still call the method, with that new property arriving as its declared default in the request it sends. How the transport format holds that up is the later post's subject. The cost is that even a method needing nothing but one string has to have a type first.
3. One Ping round trip, and four things not taken as given
Filling those two layers with actual values is what one request looks like on the way out. format is 0 here, so value is a JSON tree that can be read directly:
{
"jsonrpc": "2.0",
"method": "System.Ping",
"params": {
"format": 0,
"value": { "clientName": "curl", "traceId": "t-001" },
"type": ""
},
"id": "abc"
}
A successful response:
{
"jsonrpc": "2.0",
"method": "System.Ping",
"result": {
"format": 0,
"value": { "status": "ok", "serverTime": "…", "traceId": "t-001" },
"type": ""
},
"id": "abc"
}
result and params are the same kind of API payload, and this method's own return content is inside value. Each direction has its own property list, written on the bottom layer of the three from the previous post, the contract interface, excerpted here from IPingRequest and IPingResponse:
public interface IPingRequest
{
string? ClientName { get; }
string? TraceId { get; }
}
public interface IPingResponse
{
string Status { get; }
DateTime ServerTime { get; }
ApiKeyStatus ApiKeyStatus { get; }
string? Version { get; }
string? TraceId { get; }
}
The field names are the property names, with no mapping configuration in between: the request's value has two fields matching IPingRequest, the response's value matches IPingResponse, and the response above shows three of its five properties. traceId is on both lists, and it is the value sent in coming back unchanged. The server's PingArgs and PingResult and the caller's PingRequest and PingResponse each implement these two interfaces, so both ends read the same list for that one slot.
Set against the specification, the framework differs in four places:
| JSON-RPC 2.0 specification | The framework | What a client written to the specification runs into |
|---|---|---|
| A request can be an array, meaning one batch | Accepts a single object only | A batch fails at deserialization |
A request without an id is a notification and the server does not respond |
Always responds; when id is absent the response simply has no such field |
There is no route for sending something without waiting for a reply |
id can be a string, a number or null |
Declared as a string | A request carrying a number fails at deserialization |
A response has only jsonrpc / result / error / id
|
Sends method back as well |
One field more than the specification has |
The first three are things the specification has that the framework has not followed, and the fourth is something the specification does not have that the framework added. The protocol's role here is therefore a shared language for the format, not a guarantee of compatibility. Connecting a general purpose JSON-RPC client library straight to it runs into a wall at batching and at the type of id. The framework's own client never meets either, because the JSON-RPC payload at both ends is assembled by the same code, which is the next post's side of it.
4. How a method string becomes a method call
method is a string. Turning it into which method on which type means deciding first how the string is cut and where each half is looked up. The cut decides how large the namespace is, and the lookup decides what a typo looks like when it surfaces.
The cutting comes from JsonRpcExecutor, and is these few lines:
private static readonly char[] s_methodSeparators = new[] { '.' };
var parts = method.Split(s_methodSeparators, 2);
if (parts.Length == 2) { return (parts[0], parts[1]); }
throw new FormatException($"Invalid method format: {method}");
Three things are written here: the only separator is the period; the limit is two segments, so everything after a second period stays inside the action; and failing to produce two segments is a format error, with no leniency of the kind that omits the ProgId and falls back to a default.
A method is a ProgId plus an action. The registry from the previous post has one layer only, so a method needs just these two segments and the namespace is flat, at the cost of a ProgId not being allowed to contain a period.
| method | ProgId | action |
|---|---|---|
✅ System.Login
|
System |
Login |
✅ Order.GetList
|
Order |
GetList |
❌ System.Ping.Extra
|
System |
Ping.Extra |
System.Ping.Extra splitting into System and Ping.Extra is the direct consequence of the two segment limit: the extra segment is not caught as an error, it becomes a method name that cannot be found.
After the cut the two halves take different lookups. Before any of that, one more place matches the whole method string, so the same string is read by three mechanisms in one request, and their casing rules are not the same:
| Where it is read | What is matched | Casing |
|---|---|---|
| The transport layer's no credential list | The complete method string | Case sensitive |
| The type registry | The ProgId
|
Case insensitive |
| Reflection | The action | Case sensitive |
All three are reasonable on their own, and put together they produce a misleading symptom. Trying a method that needs no login: System.Ping responds normally, while system.Ping, one letter different, comes back HTTP 401. That string fell out of the no credential list and was stopped at the transport layer, never reaching the other two lookups at all. The cause is casing and the symptom talks about credentials.
This is not a designed rule, it is what three mechanisms' separate defaults add up to, with nothing unifying them and no check that would point it out. What the framework can do is declare the outward method names as constants, which the six in the first section are, but that only guards its own side, and a front end assembling requests by hand is still assembling strings.
Actions not going into the registry is deliberate too. Adding an exposed method means declaring it public on the business object and registering nothing, at the cost of a misspelled name only being discovered the first time it is called. This is the same trade as the previous post's naming convention for return types, using a convention to buy zero registration and pushing the error out to run time. The difference is that the earlier one still has a shape to match against, and a method name has none.
5. The fixed stops one request passes through
Every step after a request arrives is fixed, and no method can jump the queue or skip a stop. The whole pipeline spans two assemblies, with the first two stops in the HTTP layer and the rest in dispatch.
HTTP layer (the framework's controller base)
1 Content-Type check → read body → deserialize into a request object → method must not be empty
2 decide from method whether credentials are needed → check credentials → resolve the access token
Dispatch layer
3 cut method into ProgId and action
4 ProgId → business object instance (type registry)
5 action → method (reflection)
6 access control check
7 restore params.value
8 convert to the parameter type the method declares → call
9 convert the return value to the response type → place in result
Stops 8 and 9 are the two converters from the previous post.
This pipeline's design comes down entirely to two positions. Access control sits before restoration, and the relative order of stops 6 and 7 is deliberate: validation goes first, and a request that has not passed does no decryption work. That order means a caller without credentials cannot use a crafted payload to force the server through decryption and deserialization. The cost is that the access control stretch can depend only on things outside value, being the method, the format markers and the credentials, and cannot see what value holds. The meaning of that check itself belongs to The API Payload Security Pipeline: Order, Protection Levels and Keys.
Which side of the line between stops 2 and 3 a failure lands on decides the form it is reported in.
| Where it lands | Meaning | HTTP status code |
|---|---|---|
| Stops 1 or 2 | The request itself does not qualify | 4xx |
| Stop 3 onward | The request was accepted and this call failed | 200 |
Both sides return the same kind of JSON-RPC payload, and the only difference is the status code. Not being JSON, having no method, failing on credentials, all of these are rejected before the request is accepted and are reported with an HTTP status code. Every kind of call failure after acceptance, including no such method, insufficient permission and a business rule saying no, is HTTP 200 with the error placed in the error field.
error is an object with three fields:
| Field | Type | Contents |
|---|---|---|
code |
Integer | The error code |
message |
String | The error message |
data |
Anything | Supplementary information, and most paths leave it empty |
result and error are mutually exclusive, and one response carries a value in only one of them. Some of the error codes are JSON-RPC 2.0's own standard codes and the rest come from the range the specification reserves for servers to define, while what each code means, which messages can go in front of a user unchanged, and how a client turns a code back into an exception, are the subject of The Error Contract and User Visible Messages.
This line's practical consequence lands on the operations side, where monitoring and gateways see only the first segment. A save stopped by a business rule is a successful request as far as the HTTP layer is concerned, and seeing it means reading the body. This is one of the costs that necessarily follow from choosing RPC, and is the same thing as the two in the first section in a different position.
Back to Northwind
The code that opens this endpoint in the case study is in ApiController:
public class ApiController : ApiServiceController
{
}
The whole file is those few lines plus a comment, with the route, the POST handler, request parsing, credential checking and dispatch all in the framework's base class. The reason an empty subclass is still needed is that the framework's own is an abstract class, which controller discovery will not pick up.
What those few lines buy is that every stop can be replaced: ReadRequestAsync, ValidateAuthorization and HandleRequestAsync are all protected virtual, and the case study overrides none of them. One System.Ping coming back means all nine stops from the fifth section have been walked, and the code written for it is the empty class above. The Northwind Case Study: Eight Forms and a Custom Business Object said only one of the eight forms needs the application to take over, and that was the business logic side. On the transport side not even that one has to.
In closing
What a protocol really governs is not what the fields are called, it is the boundary of one call: what counts as a request, at which moment it counts as accepted, and what form a failure after acceptance is reported in.
- JSON-RPC was chosen because the number of actions does not grow with the number of forms, and because a batch of actions are not state transitions of a resource, at the cost of everything the HTTP layer can do going unused
- What sits in
paramsis an API payload, and inside it is always one parameter object, because every exposed method takes exactly one parameter - The method string is read by three mechanisms in one request, their casing rules differ, and the symptom appears at the earliest of the three
- Whether a failure lands before or after acceptance decides whether it travels as an HTTP status code or in the
errorfield
The test worth taking away comes from the third section's four differences: when an implementation departs from a public specification, the one who pays is not the implementer, it is whoever writes a client to the specification. Each of those four is a reasonable trade on its own, but not one of them is announced anywhere outside documentation. Departing is not the problem, not writing it down is.
The next post, The Connector and the Convergence of Client Responsibilities, is about the other side: how the same contract converges to one path across a desktop client, a browser, and a call inside the same process.
📚 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-16-jsonrpc-pipeline
📢 You are welcome to republish this, please credit the source.
📬 Follow for more engineering notes:
Facebook | HackMD | GitHub | NuGet
Top comments (0)