Where should business logic live?

Hi!

So my assumption was that business logic should live in a Struct until I read “A Hanami struct is immutable, and contains no business logic. They are extensible for adding presentation logic:”

I would have thought the View was where the presentation logic should be.

My simplest example is expired? on a Token. Where should this method live in the Hanami Framework if not a struct?

My operative definition of “business logic” here is anything that takes action according to business rules informed by the data.

So this simple expired? method is not business logic; it is a predicate. It is not taking action, it is answering a question. This is appropriate for a struct.

Anything that takes actions to perform business rules belongs, with few exceptions, in an Operation.

3 Likes

Depends on the purpose of presentation!

If you are presenting the information for humans, then yes; a View is where you would probably do that.

If you are presenting the information for other code, that would be a data object like a struct.

If you are presenting the data for an API, you would probably write a serializer.

If you have a presentation transform that needs to be shared between Views and Serializers, I would probably put that in the Struct unless it was specific to the render environment.

2 Likes

Another approach to this I have done is decoration. You can write a decorator with SimpleDelegator that transforms the underlying data into human-readable form.

1 Like

I think putting expired? on your struct is adding reasonable amounts of “flavour” to that struct. That code belongs there.

As a counter-example, I have view logic that is only reasonable to include in some specific contexts, over in parts: twist-v3/app/views/parts at 328a105640e33f6228ba2557825029b26de493fb · radar/twist-v3 · GitHub

pretty_note_count is probably the best example, though light on actual code:

The note_count method for an element is only available in this view thanks to some specific relation loading – it’s not typically available on Element structs ever. So I think this code belongs here so that when I’m rendering an element I can rely on being able to display that element’s note count.

2 Likes

Thanks for the feedback! I’m glad putting expired? in the struct was not outrageous. @alassek’s further definition of business logic to actions was helpful. @radar thanks for your example too.

This clarifies that statement in the guide for me. I wonder if the They are extensible for adding presentation logic could be changed to something less loaded than presentation. Maybe They are extensible for adding read only interpretations of the data they expose ?? Not sure…

What if it’s some complex calculation, such as calculating a “hotness score” for an article, based on publication date, recent views, number of comments and author’s reputation? It spans multiple records, of which the Article is kind of a root, but it does not feel like an operation (assuming it’s not changing the state of the system). It’s not a simple predicate too. Where would it live?

It’s an operation – it’s an action / task / OPERATION to calculate the hotness score. I think it would be fine to live in its own operation class.

1 Like

Right. It’s not how I usually think about operations, but it makes perfect sense. I guess the question remains if it’s ok to call an operation from the view…

In that case, I’d personally pull out a “Query” concept, and let plain “Operation” mean something that mutates data.

Then you can have a Queries namespace and parent Query class which can inherit from Dry::Operation or your app’s Operation, if you want. (Or it could be a subclass of Repo, depending on what you want.)

You could also just keep it in the Operations namespace and append Query to the end of the class name, e.g. HotnessQuery, to signify that it doesn’t mutate data to keep it simple in the short-term.

4 Likes

I would start by doing this in a Repo method, but if it feels too complex to put it there, then I would move it to an Operation.

Even administrative stuff that runs in background jobs should be written as Operations and called from the job code.

It’s my principal abstraction for all business logic, because I can treat them like basically pure-ish functions, which is maximally easy to test.

I’ll give you a couple examples of things I do not use Operations for:

HTTP adapter. This is injected into the gateway and is solely responsible for handling stuff like authn, serialzation/deserialization. Response objects are wrapped in Result monads.

API gateways. Eventually I want to experiment with rom-http for this, but for now I do the simplest thing that works by writing a PORO with dry-initializer and dry-auto_inject dependencies, and a method per API call. Translates an object-based public API into HTTP parameters forwarded to the HTTP Adapter.

OAuth JWKS. OOP works best for this, I use a class wrapper around JWT::JWK::Set with helper methods for mutating the keys and validation

1 Like