A bunch of notes and observations on working and designing microservices. There are not many assumptions in the text except the perspective of functional programming (F# in this case).
- Commands trigger workflows.
- Workflows are the keys of business logic. They glue together I/O reads with functions that manipulate the domain objects on which I/O writes are performed afterwards.
- The reason they can accept other functions (dependencies) means they can use mocked domain types.
- Domain types should be strongly typed. Raw strings, bools, or ints should prohibited.
DateTimecan be too vague in some cases when domain logic only allows dates in the future.
- Workflows accept valid input. They execute business actions, thus they should be communicated using validated domain types.
- Using domain types for workflows means that all input should be valid so that workflows do not need to execute type validation. They might of course return errors that result from unattainable business actions, e. g. trying to run new screening for a domain might fail with Domain Error in case there’s no more scans left for the requester.
- This also means that actions requested by ports (like HTTP requests or external Azure Function triggers) should trigger workflows only by passing parsed arguments. Few reasons:
- There’s no point to pass invalid arguments to the domain layer.
- Domain layer should not operate on raw strings and unchecked ints or bools.
- Different ports might want to parse inputs to the same domain types but using different parsing functions.
- Commands should be delivered to the domain logic the same way: using validated domain types. Sometimes instead of passing several arguments, it can make more sense to prepare dedicated command objects as domain types wrapping them.
- At last, DTOs are not part of the Domain layer! There’s no reason to keep them there.
- It’s very rare to have 1:1 mappings with the input values because almost always they must be parsed, validated, and converted to domain types.
- Workflow outputs should be of domain types as well. That’s because workflows can understand only the domain types and because it’s the responsibility of the App or Api layer to prepare the output. Moreover, in our case outputs for commands are not very common because most often we do return empty responses.
- Workflows might execute I/O operations (DB reads/writes, API calls) to retrieve data needed to perform actions using functions implemented in the adapters/gateways layer. These adapters should understand domain types the same way as workflows do, e. g.
UserQueries.getUsershould returnUserdomain type (converting it from the internal DTO). In case when instantiation of this object fails the function could returnResult<User, ...>. It’d be more pure to handle any parse errors from a database. But it’s also fine to simply throw an exception if no corrective action can save the workflow.CompanyApi.createCompanyshould be able to accept parameters of theCompanydomain type (and convert it to desired DTO just for communication purpose).
Queries are workflows but different. Almost all the data that come to a service is a result of commands execution – the workflows. Surely, we want to return the processed commands to the user in most cases. And we need to be able to pass parameters to adjust query responses. In general we have two options:
- Queries can be treated similarly to workflows. In this approach adapters returning data can be reused and we don’t need to reinvent queries! Using query outputs that are domain types, it’s quite easy to convert the domain types to response objects in the App (or Api) layer. Since the rules are very similar to the ones used for workflows, queries in this approach are workflows but just named as queries. The important rules include:
- The input (query parameters) should be of domain types.
- The output should be of domain types as well.
- Queries are separated from workflows. There can be multiple reasons for doing so but the most obvious is the performance that can only be achieved using dedicated SQL queries or even data that is reshaped and prepared for reads. Performance or not, we could decide that for some services data querying uses model that is so much different from commands that it’s better to use model that does has little in common in the domain types. Even if endpoints differ only by GET and POST. Few remarks about this approach:
- It is necessary to validate the input anyway. It means that some sort of domain types for the input (query parameters) is required.
- Queries must be kept in sync with commands. In cases where there are changes in the database model for changes introduces by commands, it’s necessary to reflect this change not only in the SQLs used by the command but also the ones that are created for queries.
- Performance is not an issue until it is. There’s no point to optimize by creating separate query model for the future. If necessary, it’ll be relatively easy to replace the inner query logic keeping the response contract unchanged.