Use orchestrator service objects
NOTE: ADR 0033 Service Object Layer is the one that started us on using service objects. This ADR doesn't supersede it, but it may be helpful to read the other one if you want more background on why we use service objects.
Problem statement
Service Object Background
We introduced service objects in ADR 0033 Service Object Layer. We use them to leave business logic and database details/transactions out of the handlers. Ideally handlers should only focus on translating data from the protocol layer to the service layer and vice-versa. Service objects also allow us to more easily re-use business logic across the codebase as needed, including re-using service objects across handlers to keep things consistent across our endpoints and APIs as a whole.
Summary
There are actions that require several things to happen across related models, e.g. creating an MTOShipment
may also
need MTOAgents
and/or MTOServiceItems
to be created. The question is then, should we have a single service object
that knows how to do all the things, or break things down and orchestrate service objects somehow?
There are also actions, like routing a shipment, that have their own service objects, but then how should those be
handled when it comes to the existing MTOShipment
service objects?
Story Time
When we started working on implementing the logic to handle PPMs, we decided to start a pattern of having shipment types
have their own model that will be a child of the MTOShipment
model (see
ADR-0067 Add a child table to mto_shipments for PPMs for more details).
This meant we needed to add logic for creating and updating PPMShipments
, but we weren't sure where to add it because
we don't have a set standard for this. The ADR that started us using service objects (see note at top) is more focused
on the fact that we should use service objects, but doesn't really get into when a new service object should be created
vs expanding an existing one, or how to handle the times that you need multiple service objects in order to perform a
full action. If we look at the existing code base, we have implemented a variety of different patterns, which makes it
harder to maintain, and more confusing for folks adding new logic.
For PPMShipments
, we opted to create separate service objects from the existing MTOShipment
service objects, but due
to still being in the middle of discussing this ADR, we ended up with two different implementations for how they
interact with the MTOShipment
service objects. The PPMShipment
creator internally calls the MTOShipment
creator,
while the PPMShipment
updater expects the MTOShipment
updater to be called separately (currently being done in the
handler). The Considered Alternatives section will use these as examples.
Measuring success
Initial success would be:
- Have our first orchestration service objects set up for managing shipment-related service objects.
- At first this would primarily be focused on coordinating existing
MTOShipment
andPPMShipment
service objects.
- At first this would primarily be focused on coordinating existing
- Team members know about this ADR and have a good example to work from (the first orchestration object mentioned above).
- Have documentation around these service objects to help people when they need to work on them.
Long-term, we would ideally:
- Expand documentation to have helpful information to help people decide when to use orchestration service objects vs having service objects take other service objects as dependencies or some other method.
- Evaluate how the existing
MTOShipment
service objects handleMTOAgents
,MTOServiceItems
, and shipment routing and see if they could/should move to using the orchestration service objects. - Evaluate other service objects to see if the can benefit from having a higher level orchestration service object.
Observability
How will this change be observed by other team members?
This ADR will be announced in meetings where most of the MilMove team is present. If approved, as orchestration service objects are added or updated, they could be announced at internal demos and/or BE check in meetings.
Implementation Plan
- Create new orchestration service objects for managing existing
MTOShipment
andPPMShipment
service objects. - Update existing usages of the
MTOShipment
andPPMShipment
service objects to use the orchestration service objects instead. - Update documentation around service objects to include info about this new kind of service object, with examples as appropriate.
- Announce new service objects at demo and BE check in.
Ownership
AppEng would own this since it's related to the way we work with the business logic of our application code.
As the need for orchestration service objects arises, it would be up to the person writing code and reviewers to ensure orchestration service objects are used, added, or update as needed.
Considered Alternatives
For these alternatives, we'll keep talking about MTOShipment
and PPMShipment
service objects to give concrete
examples, but the idea for this ADR is for the decision to be applicable in to any situation where we have multiple
service objects that are needed for an action.
- Use separate service objects and the handler decides which to call.
- In this one, the
PPMShipment
service objects will internally call theMTOShipment
service objects to do whatever needs to be done to the parent MTO object. - This is how
PPMShipmentCreator
works now.
- In this one, the
- Use separate service objects and the handler orchestrates the calls.
- So for example, we need an
MTOShipment
to exist before we create a PPMShipment, so the handler would call one, then the other. - This is how
PPMShipmentUpdater
works now.
- So for example, we need an
- Update existing service objects to contain the new logic.
- An example of this is how the
MTOShipment
service objects handleMTOAgents
andMTOServiceItems
internally rather than calling other service objects.
- An example of this is how the
- Have separate service objects, but the handler just calls the existing service objects. Then we update the existing
service objects to call the new service objects as needed.
- This is how routing works currently. The
MTOShipment
service objects will call the shipment router service object as needed.
- This is how routing works currently. The
- Have composable service objects and create orchestrator service objects that then call the appropriate service
objects.
- Not sure if we have examples of this.
- Leave things as they are.
Decision Outcome
Chosen Alternative -> Option 5: Have composable service objects and create orchestrator service objects that then call the appropriate service objects.
While it would take some work up-front, and might add work as new service objects are created, this seems to be the cleanest path for what we need. It keeps business logic out of our handlers, while at the same time still keeping our service objects from getting too large, since each can focus on just what it needs to do. This also leaves us with a codebase that's easier to test since each service object can be isolated as needed in tests.
Pros and Cons of the Alternatives
Option 1: Use separate service objects and the handler decides which to call
+
Keeps some service objects from getting too large since they only have to worry about their own thing, calling other service objects as needed.+
This makes service objects work as standalone objects. E.g. theMTOShipment
service objects can do their thing without worrying about other things, while thePPMShipment
service objects would know to call theMTOShipment
service objects as needed so they wouldn't need something else to coordinate the calls.+
It is easier to mock out service objects if they are separate objects than if they are all lumped into a single one.-
This puts business logic and database details/transactions in the handlers. Ideally handlers should only focus on translating data from the protocol layer to the service layer and vice-versa.
Option 2: Use separate service objects and the handler orchestrates the calls
+
Keeps service objects from getting too large since each will encapsulate the logic they need for their own work.+
It is easier to mock out service objects if they are separate objects than if they are all lumped into a single one.-
This puts business logic and database details/transactions in the handlers. Ideally handlers should only focus on translating data from the protocol layer to the service layer and vice-versa.-
Testing at the handler level takes more setup than testing at a service object level so testing the orchestration of service objects would take more work this way.
Option 3: Update existing service objects to contain the new logic
+
Leaves business logic and database details/transactions out of the handlers. Ideally handlers should only focus on translating data from the protocol layer to the service layer and vice-versa.-
This makes our service objects become incredibly large and hard to maintain. As it is, we already have some service objects, like theMTOShipment
ones, that contain a large amount of logic and can be hard to parse through.-
Related to the point above, but finding the logic for a specific type of change, e.g. tracking how PPMs change, would be harder to do if it's all in one service object.-
Testing large service objects is hard because you need to account for many branches of code.
Option 4: Have separate service objects, but the handler just calls the existing service objects. Then we update the existing service objects to call the new service objects as needed
+
Leaves business logic and database details/transactions out of the handlers. Ideally handlers should only focus on translating data from the protocol layer to the service layer and vice-versa.+
This makes service objects work as standalone objects. E.g. thePPMShipment
service objects can do their thing without worrying about other things, while theMTOShipment
service objects would know to call thePPMShipment
service objects as needed so they wouldn't need something else to coordinate the calls.+
Might keep some service objects from getting too large since they only have to worry about their own thing, calling other service objects as needed.+
It is easier to mock out service objects if they are separate objects than if they are all lumped into a single one.-
Related to the point above, there is the potential for service objects to still get large though as more connections arise. E.g. if every shipment type had its own service objects, theMTOShipment
service objects would need to have their own base logic, plus the logic for calling each of the related service objects correctly, which could expand to be too large.
Option 5: Have composable service objects and create orchestrator service objects that then call the appropriate service objects
+
Leaves business logic and database details/transactions out of the handlers. Ideally handlers should only focus on translating data from the protocol layer to the service layer and vice-versa.+
Keeps service objects from getting too large since each can focus on doing their own thing.+
The orchestration service objects could serve as a nice way of viewing all the steps needed for an action at a high level.+
It is easier to mock out service objects if they are separate objects than if they are all lumped into a single one.-
Requires work to create the orchestration service objects that we need now.-
Potentially adds more work when creating new service objects if you need to create both the main service objects you're focusing on, plus orchestration service objects if needed and they don't already exist.
Option 6: Leave things as they are
+
No extra work is needed right now.-
We are left with code that implements most of the options and is inconsistent even within the same service objects.-
This leaves some cases of business logic and database details/transactions in the handlers. Ideally handlers should only focus on translating data from the protocol layer to the service layer and vice-versa.-
We are left without a standard that would help guide future folks.