DEV Community

Cover image for Day 21: Business Logic Customization and the Four Plugin Stages
Jeff Tsai
Jeff Tsai

Posted on Originally published at hackmd.io

Day 21: Business Logic Customization and the Four Plugin Stages

Multilingual Customization and Resource Structure ended on a situation where the difference one company wants is too large for language resources or layouts to hold. Requests of that kind have a different shape from the two posts before: one more credit limit check before a save, a document synchronized to another system after a save, a notice to the warehouse on a delete. What they want is not a definition that reads differently, it is a piece of behavior that runs.

The framework already has a route for that: inherit the whole form's business object and swap the binding in the registry. But that route is too heavy for the three things above. Sending one notification after a save means taking over the business object for the entire document. Three unrelated customizations stacked together can only be written into the same subclass. And whether logic the package adds to that step later still runs at all depends on whether the subclass remembered to call base. What is missing is not an extension point, it is a way to hook in that weighs less than inheritance and can be declared per customization.

This post covers:

  1. Which four stages a plugin hooks into, and what each one suits
  2. Why the settings file carries the stage as well, and what one class per stage buys
  3. Once the two layers are added together, where failures and outward side effects belong
  4. When to reach for a plugin, and the line it cannot cross

1. The four plugin stages

How many positions to open and where to open them is the first question a mechanism like this has to answer.

The framework opens four: BeforeSave, AfterSave, BeforeDelete and AfterDelete. Put back into the save pipeline from Extension Points on a Business Object and When to Override Them, they sit here:

DoBeforeSave
    BeforeSave        ← plugin
[capture the change set]
DoSave                ← the transaction covers only this one line
[change auditing]
DoAfterSave
    AfterSave         ← plugin
Enter fullscreen mode Exit fullscreen mode

The square brackets are work the framework does itself, not extension points. The delete side has the same shape: DoBeforeDelete → BeforeDelete → DoDelete → delete auditing → DoAfterDelete → AfterDelete, where BeforeDelete and AfterDelete are the plugin stages.

Each stage follows one step of the pipeline, and each suits something different:

Stage Right after What it suits
BeforeSave DoBeforeSave The only one of the four where data can be changed safely
AfterSave DoAfterSave Notifications and outward synchronization
BeforeDelete DoBeforeDelete Checks made against the whole row snapshot
AfterDelete DoAfterDelete Propagating the delete outward

All four sit outside the transaction, so the boundary drawn in that same earlier post still applies: a value read here is not guaranteed to still hold at the moment of the write. Genuinely blocking someone else who is saving at the same time means letting the database decide it at the moment of the write, through an UPDATE that carries its own precondition, a unique index, or a check constraint.

Every stage follows its corresponding Do step, and the last thing that step ran may have been the package's base or a version a customized business object overrode. Inheritance and plugins are two neighboring layers on that same axis, differing only in granularity and in how much control they take, which is why both can be used together.

What makes BeforeSave particular is that it comes before the change set is captured and before the write, so a value changed there goes into the database and stays in the audit trail. One notch further along it no longer does: the data is already written, changing the DataSet in AfterSave has no effect, and what has to be changed is the RefreshedDataSet the caller will receive. That dividing line was drawn on SaveContext earlier in the series, and a plugin is handed the same object.

The snapshot on the delete side was covered there too, and only one thing needs adding here: when the framework decides whether to load it, whether there is a plugin counts toward that decision. Once the data is deleted, AfterDelete is the only thing left that can still see what was deleted.


2. The stage is written in the settings file, and the class has to line up

Which piece of customization runs at which stage has to be visible in the settings file. PluginSettings therefore writes two things on one row, the type and the stage:

<PluginSettings>
  <Items>
    <ProgramPluginItem ProgId="Order">
      <Plugins>
        <PluginItem Type="Acme.Plugins.CreditLimitCheck, Acme.Plugins" Stage="BeforeSave" />
        <PluginItem Type="Acme.Plugins.OrderSync, Acme.Plugins" Stage="AfterSave" />
      </Plugins>
    </ProgramPluginItem>
  </Items>
</PluginSettings>
Enter fullscreen mode Exit fullscreen mode

One ProgId maps to a list of plugins, every Stage has its own class, and one class carries only one stage. The example is therefore two classes: the check before a save and the synchronization after one were two separate things to begin with. Splitting them also means each can be picked out on its own, so another customer already using the same external system only has to declare OrderSync, without dragging the credit limit check along with it.

The class side has to line up: inherit FormBusinessPlugin, pass the constructor arguments the base needs upward, and override the one stage declared in the settings file.

public sealed class CreditLimitCheck(IBeeContext ctx, Guid accessToken, string progId)
    : FormBusinessPlugin(ctx, accessToken, progId)
{
    public override void BeforeSave(SaveContext context) { /* throw if the limit is exceeded */ }
}
Enter fullscreen mode Exit fullscreen mode

3. Once they are added together, where does a failure go

The rules and the cost of adding two layers together were already covered in The Scope of the Customization Layer and Overlay Granularity: the package chain runs first, the customization chain follows it, and a customization has no way to disable a plugin the package declared. One more thing has to be decided once they are added together, which is what happens when any link in that chain fails.

Both kinds of failure are raised, with nothing skipped and nothing degraded:

Kind of failure Handling
A declared type cannot be loaded Thrown straight away, and the whole operation fails
An exception at run time Propagated upward, aborting the operation

That is the same answer as the business object axis, for a different reason. On that side there is no fallback because falling back to the framework's default type still constructs successfully, and the program then quietly behaves as though it were generic. On this side it is because a plugin was deliberately added by somebody, so skipping it means the whole customization did not take effect, and silently dropping a credit limit check is far harder to deal with than a refused save. When the registry's failure policy came up earlier in the series the test was what a fault looks like, and this is another instance of the same test.

Which side an outward side effect belongs on

The framework only governs the order of execution. It calls the plugins on the chain in the order the settings file declares, catching no exceptions in between and making no judgment about whether to continue. So what happens after one plugin fails is decided by whoever wrote it, and there are only two choices: throw, and the whole operation aborts; or catch it, record an error, and let the flow finish.

The usual split follows the stage, throw on Before and record on After. The two Before stages are mostly doing validation, and something that cannot be stopped there should not be allowed in. At the two After stages the data is already committed, and throwing then amounts to reporting failure over data that is already stored. It also means that if the process dies before the plugin finishes, the result is data present, synchronization not done, and no trace left.

The most common use of After is synchronizing a change into another system, which is also the easiest place to get wrong: throwing there means users cannot save a document at all while the other system is down for maintenance. The framework adds no mechanism for this, and anything it added could be bypassed by one try-catch wrapped around the whole block. It only makes the responsibility explicit: do not let another system's availability decide whether a piece of data can be saved.

Reliability requirements therefore split the position in two:

Reliability requirement Where it belongs
Must not be lost (finance, inventory, a commitment made to an outside party) A record queued inside the transaction, sent afterward by a background job
Best effort, or recoverable by a separate reconciliation run Sent straight out from an AfterSave / AfterDelete plugin

The second of those works today. The first does not: there is no such implementation in the framework, and none of the six write steps described earlier can append a statement to the transaction already in progress. The framework can say which side this belongs on, but it has not opened that position yet.


4. When to reach for a plugin

When a customer raises a request, the first thing to ask is whether it can be solved by changing a definition. A field caption worded differently, three fewer fields on a screen, a menu ordered the way their organization is, all covered by the two previous posts, change one definition and touch no code.

Line the common requests up together and the boundary is clear:

What you want to change Package layer Customization layer
Field captions, dropdown option text Change the language file Change a customized language file
Three fewer fields on a screen, a different arrangement for the detail Change the layout file Change a customized layout file
A menu that reads differently Change the menu file Change a customized menu file
One more field Change FormSchema and TableSchema No route at all
A changed computation rule or validation rule Change the expression in FormSchema Hook in a plugin, or replace the whole business object

The first three hold in both layers, which is the line from Why ERP Needs a Definition-Driven Framework: a definition is data, and changing one needs no rebuild and no new assembly. The fourth holds only in the package layer, for the reason covered two posts ago: FormSchema also decides what the table looks like, and letting it diverge per customization would split the physical structure along with it.

The last is where a plugin belongs. Rules and expressions are declared inside FormSchema, and that definition cannot be customized, so a customer who wants one expression changed falls back to code. The requests at the top of this post are the same: a definition describes what the data looks like, and there is nowhere in it to put a piece of behavior that runs.

Once you are back in code, a plugin is the lightest of the options: no taking over an entire document, stackable, declarable per customization. But it is still a compiled type, and it has to be built, packaged and placed in the host's bin before it takes effect. The same change that is one edited expression in the package layer has to travel that delivery path in a customization. The range where shipping nothing new holds completely is the package layer.

There is another side to this over a longer stretch. A form in use for a few years accumulates several plugins, and the ones that actually get reused are mostly those talking to external systems: pushing customer records into a CRM, sending documents into a BPM approval flow, writing delivery dates into Outlook or Google Calendar. That kind is bound to the other system rather than to one customer, so a new customer already using that system only has to declare it, which amounts to picking a few rows out of an existing list.

Business rules of a company's own do not have that property. How a credit limit is computed, how document numbers are assigned, are mostly one per company, and what gets written usually has only that one company using it. But this kind is still worth taking stock of periodically: if several companies all wrote the same behavior, that is not customization, it is a shared requirement that has not been consolidated yet.


In closing

A plugin is the slot between inheritance and a definition file. It does not take over the whole flow, it only adds a piece at each of four fixed positions. The two layers are added together, so what the package adds later takes effect on its own. One class carries one stage, and the settings file reads out which piece runs where.

  • All four stages sit outside the transaction, so the boundary from the post on extension points still applies: a value read here is not guaranteed to still hold at the moment of the write, and BeforeSave is the only position where data can be changed safely
  • Failure handling matches the business object axis, with no fallback on either side, but the thing each one fears is different: that side fears a program quietly behaving as though it were generic, this side fears a customization that did not take effect at all
  • Outward synchronization that must not be lost belongs inside the transaction, and that position has not been opened yet. What the After stages can carry is the best effort kind, or the kind a separate reconciliation run can recover

The three posts in this chapter ask the same question from three angles: what does the difference a company wants finally get expressed as. Expressed as a definition, it can be overlaid, rolled back, and carried along as the package evolves. Expressed as a type, it has to be compiled, packaged and placed in the host's bin, which puts it back on the old road where the size of a request and the cost of delivering it are thoroughly decoupled.

So the test is not how much an extension mechanism can do, it is which delivery path it has to travel. A plugin lowers the bar for customization from taking over an entire document to adding one class, which is a genuine step, but it does not cross the line between a definition and code. What this line of customization has to push forward next is moving more of the difference from the code side back to the definition side.

The next post, The Division of Labor Between XML, JSON and MessagePack, changes the level. It starts from one object that has to be storable as a file, sent to a browser, and read back on a mobile device, all at once.


📚 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-21-business-plugin

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

Top comments (0)