0

I frequently have relatively contained components (services) that do specialized things. These are also almost always immutable to make changing them less error prone. Note the below is within one relatively big project, not small services in separate processes, but that doesn't change much.

Take this system as an example:

  1. Input: cardboard package that contains red books
  2. Processing
    1. Take a book
    2. Do some complex processing such as:
      1. If book has less than 50 pages, reject with message "Too short"
      2. Otherwise, read pages 15, 21 and 38
      3. Find all words that start with X on pages 15 and 21
      4. If we have more X-words on page 15, find the number of occurrences of the word "play" on page 38, otherwise read the last word on that page. Make that be A
      5. Write a new book where you change all the words "some" to A. If there are no words "some" at all, issue a warning message "No matching words found"
      6. Print it and color the covers blue
  3. Output: plastic package that now contains blue books and various rejection or warning messages as needed

Consider that we now have a lot of services that perform things in the 2.2 part above. E.g. there would be a service that writes a book in step 2.2.5.

Consider also that the rejection or warning messages are to be propagated across all services. In the above simple case that might not sound as much, but consider that this is usually much deeper than the above example. E.g. we might have something like:

[one thread]
book service
-> chapter service
--> title service
--> paragraph service
---> sentence service
----> word service
--> footnote service

[another thread]
printing service
-> font chooser service
-> cover coloring service

This means that either we keep global state (yuck) or create gazillions of separate objects (SentenceServiceOutput, PrintingServiceOutput, ...) and make each of the services return all pieces, i.e. write book would return (book, reject messages, warning messages) tuple and then wire them as needed in some parent service.

The 2nd solution has some drawbacks in that it:

  • Pollutes the code significantly and make it very hard to read and trace through. E.g. instead of a service (2.2.3 above) that returns a list of words found, it would return an object ([words], [reject message], [warning message])
  • Makes the code significantly longer and more convoluted - basically wherever we had [words] we now have to unpack ([words], [reject message], [warning message]), change a small piece and then re-pack into a new ([words], [reject message], [warning message]).
  • All this makes it significantly harder to keep the overall project structure in your head, since the number of names (e.g. class names) is increased dramatically and due to their number names become longer (you cannot call it just Output, since you'll mix SentenceServiceOutput and FontChoosingServiceOutput), making the code even longer and harder to read
  • While it still makes it significantly easier to reason about smaller pieces than using global state, makes the code changes significantly slower and harder to audit, test & such

Are there any patterns which allow for mitigation of any or all of the above issues?

  • the rejection or warning messages are to be propagated across all services What do you mean propagated? How are the services using the rejection or warning messages that are raised by other services? Is it just to store them for display to an end user, or is there business logic that depends on them? – John Wu Dec 10 at 0:41
  • @JohnWu Definitely for display purposes, but it could also be part of the business logic. E.g. a service A could service B. If service B call goes fine (so no rejection / warnings issues), A would return that result. However if there were rejections, A could then try service C, which then might succeed, so A would return its result or fail otherwise. That's however of a lesser importance - the point is all these need to be propagated through all services to keep them stateless / immutable. – levant pied Dec 10 at 14:07
0

So if I understand this correctly, you have data (rejections and warnings) that may accumulate while your services are handling a request. You'll somehow need to make some kind of request-scoped data structure available to your services into which they can place their messages.

As you've eloquently indicated yourself, global state seems like a bad solution to this problem.

Context

In essence, I see two general approaches.

Option A You have a generic context data structure that all your services accept. This also means that you have a common message format and it's the service's responsibility to somehow match that format.

Option B Each service defines its own context interface. This moves the problem of conforming to a fixed message format from the service to a context implementation that needs to somehow deal with all the different kinds of messages.

If the capabilities that the context needs to provide to the services are relatively similar, stable and have a simple enough interface (e.g. add this message string with this service id to a list), then I think making the services depend on the context object is the better approach. Hence, I would choose option A.

In option B, the service context has to depend on all services (which isn't great) and the part of the service-specific logic that deals with message handling is now in another module. That may still be better than forcing the services to use a context object that just doesn't work for them, e.g. because they need to do vastly different things. The upside is that the services have no additional dependency that ties them all together.

If it is possible for messages to be brought into the same format, but it requires some complex logic, you might combine option A and option B: you have a generic context implementation and use adapters between the services' messages and the generic message. This way you can keep that logic closer to the service and still avoid coupling and keep the context interface clean.

Control Flow

While you accumulate your rejection and warning messages in the context object, the actual data that is manipulated and upon which you make control-flow decisions should not be part of it.

Ideally, the services should never have to query the context, just append to it. Otherwise, you're hiding important inputs/outputs in the context object, obscuring the service's interface.

Use option types, tuples or whatever else you need to keep the data and control-flow relevant information in your parameters and return values. If your control-flow depends on a rejection-reason, encode this separately to the rejection message.

Final Notes

  • Keeping the messages separate from the inputs/outputs should keep the signatures easy to understand, once you've taken a look at the (hopefully simple) context interface. Making the context interface write-only should further clarify that it doesn't contain any inputs.

  • If you don't use a common context interface, you might end up with AbcContext, XyzContext. Most likely, you'll do this because they are different in important ways. Any complexity inherent in the domain will eventually show up in your code. Good naming (and namespaces), as well as good structure (which you appear to have) helps to keep things manageable.

  • Keeping things easy to understand locally should help with keeping them easy to understand globally. This is why you want to clearly express inputs & outputs of your service. This also makes testing easier (btw, I'm not sure how global state would ever help with that).

  • If your services are inherently hierarchical, a matching hierarchy of contexts may be worth considering.

P.S.: Would you mind if I send you a package with red books? Those blue books sound really interesting... ;-)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.