Exposing Operations: Command Service¶
Introduction¶
After being learned how to setup a minos
microservice and knowing the Aggregate
basics and being able to start working on the domain model, the next step is to expose the implemented functionality outside, to be used both by another microservices and external clients! One of the main advantages of using minos
is that it is able to expose endpoints over different interfaces transparently, so that the infrastructure details (that in many cases require a big effort) are handled by the framework and the business logic becomes into the leading actor of the microservice.
The minos
framework is based on the CQRS pattern, which is characterised by the partition of the system into two main parts: the one focused into Command
processing and the one focused into Query
resolving. Following the event driven ideas, the CommandService
is the one who processes requests that modify the internal state of the microservice, with changes on the Aggregate
, that generate events, that can be handled by the QueryService
, who updates its own query-oriented database. Then, the requests that will not change the state of the microservice (so that, the ones that are read-only) should be resolved by the QueryService
who asks its own database.
The rest of the section is focused into the CommandService
and the basic syntax to expose endpoints. Next, the Exposing Information: Query Service guide is fully focused into the QueryService
and how to read and update the user-defined query database.
Setting up the service!¶
The setup process of the services is mostly equivalent both for the CommandService
and the QueryService
as they share the same interfaces configuration. Currently, the currently supported ones are the HTTP REST and the Kafka‘s broker, but there are plans to increase the list with gRPC, RabbitMQ, etc.
The second important part in the configuration file is the commands
and queries
, in which the CommandService
an QueryService
classes are being setup.
# config.yml
rest:
host: 0.0.0.0
port: 8082
broker:
host: localhost
port: 9092
queue:
database: exam_db
user: minos
password: min0s
host: localhost
port: 5432
records: 1000
retry: 2
commands:
service: src.commands.ExamCommandService
...
After being set up the services’ configuration, the next part is to start writing the code itself! In this case, the command service is defined into the src/commands.py
file:
from minos.cqrs import (
CommandService,
)
class ExamCommandService(CommandService):
...
The most important part here is the minos.cqrs.CommandService
import. In minos
, the cqrs
module is the one who implements the abstract service classes, both for commands and for queries.
Another important part is how to expose and set up the endpoints. In this case, minos.networks
provides the @enroute
decorator, that can be seen as a hierarchical decorator composed by the following parts:
@enroute.rest.command(url, method)
: Exposes a Command request over the REST interface.@enroute.rest.query(url, method)
: Exposes a Query request over the REST interface.@enroute.broker.command(topic)
:Exposes a Command request over the Broker interface.@enroute.broker.query(topic)
:Exposes a Query request over the Broker interface.@enroute.broker.event(topic)
: Exposes an Event request over the Broker interface.
After being learnt how to setup the services on the configuration file and take an overview over the CommandService
base class and the @enroute
decorator, it’s time to introduce the expected function’s prototype for each exposed endpoint:
from minos.networks import (
Request,
Response,
)
class MyService(...):
@enroute.rest.command(...)
@enroute.broker.command(...)
async def make_foo(self, request: Request) -> Optional[Response]:
...
The minos.networks.Request
and minos.networks.Response
are the classes with the responsibility to provide access to the received request itself and to build the given response. The main advantage of these two classes are that they are able to fully isolate the surrounding network interface that received the request, so that the code keeps much more simple. One thing to notice is that the endpoint does not need to necessarily return any value because there are some cases in which has no sense from the business logic perspective. Also, it’s important to notice that the minos.networks.ResponseException
provides a way to notify that there was some kind of situation that does not allow resolve the request successfully.
Now that the basic concepts to expose operations that can be used both by another microservices and also by external clients, the next step is to include some examples that help in the process to understand it better.
Exposing Exam
creation…¶
A common operation on a microservice is the creation of Aggregate
instances. Here is an example of Exam
‘s creation, defined on the ExamCommandService
:
from minos.common import (
EntitySet,
)
from minos.networks import (
enroute,
Response,
Request,
)
from .aggregates import (
Exam,
)
class ExamCommandService(CommandService):
@enroute.rest.command("/exams", "POST")
@enroute.broker.command("CreateExam")
async def create_exam(self, request: Request) -> Response:
content = await request.content()
exam = await Exam.create(content["subject"], content["title"], EntitySet())
return Response(exam.uuid)
In this case, the command is exposed both over the REST and Broker interfaces. The way to extract the information from a Request
instance is through its content()
method, that in this case is assumed to be a dict
instance containing the subject
and title
fields. The return
value in this case is a Response
instance, in which the content is composed of an UUID
instance, but another composed options would have been possible. To know more about that, it’s recommended to visit [TODO: link to Request/Response architecture section].
Adding Exam
questions!¶
Similarly to Exam
‘s creation, another possible command could be the question adding:
from minos.common import (
ValueObjectSet,
)
from .aggregates import (
Question,
Answer,
)
class ExamCommandService(CommandService):
...
@enroute.rest.command("/exams/{uuid}/questions", "POST")
@enroute.broker.command("AddExamQuestion")
async def add_question(self, request: Request) -> Response:
content = await request.content()
exam = await Exam.get(content["exam"])
answers = ValueObjectSet([Answer(raw["title"], raw["correct"]) for raw in content["answer"]])
question = Question(content["title"], answers)
exam.questions.add(question)
await exam.save()
return Response(question.uuid)
As it can be seeing, the main structure of the handling method is really similar to the one for Exam
‘s creation. An interesting difference here is that the generated ExamUpdated
event will contain only the Question
instance that is being created as it is stored inside an EntitySet
. This is really important from the QueryService
perspective, as it will be seeing in future guides.
Deleting Exam
…¶
Exam
‘s deletion could be another common use case on a exam
microservice. It can be implemented with a handling method like:
from minos.common import (
MinosSnapshotAggregateNotFoundException,
MinosSnapshotDeletedAggregateException,
)
from minos.networks import (
ResponseException,
)
class ExamCommandService(CommandService):
...
@enroute.rest.command("/exams/{uuid}", "DELETE")
@enroute.broker.command("DeleteExam")
async def delete_exam(self, request: Request) -> None:
content = await request.content()
uuid = content["uuid"]
try:
exam = await Exam.get(uuid)
except MinosSnapshotAggregateNotFoundException:
raise ResponseException(f"The exam does not exists: {uuid}")
except MinosSnapshotDeletedAggregateException:
raise ResponseException(f"The exam is already deleted: {uuid}")
await exam.delete()
The interesting part of this handling method is that it does not return any Response
, which is a valid implementation, but it raises a ResponseException
to notify the caller about the reason why the deletion could not be performed.
Deleting Exam
by events…¶
The Exam
aggregate contains a ModelRef
to an external Subject
aggregate, that it’s assumed that is defined into another microservice. In classic monolithic systems that uses relational database schemas, a ON DELETE
statement defines how instances with references will behave after a deletion of the referred instance. As minos
follows the event driven ideas, it’s also possible to perform operations after an event is published. So, a common use case of that could be to implement the ON DELETE CASCADE
strategy between Exam
and Subject
:
from minos.common import (
Condition,
MinosSnapshotAggregateNotFoundException,
MinosSnapshotDeletedAggregateException,
)
from minos.networks import (
ResponseException,
)
class ExamCommandService(CommandService):
...
@enroute.broker.event("SubjectDeleted")
async def delete_exam_by_subject(self, request: Request) -> None:
content = await request.content()
exams = {exam async for exam in await Exam.find(Condition("subject", content["uuid"]))}
for exam in exams:
await exam.delete()
One of the main advantages of an event based implementation is that it’s very easy to understand and implement. Also, it keeps the dependencies on the right direction and improves isolation of each component in the system. [TODO: Tell the saga based alternative.]
Why not to implement get
operations as commands?¶
The queries (or get operations) are a special kind of operations characterised by the absence of side effects. As the minos
framework follow the CQRS pattern, it is highly recommended not to implement get
operations to be resolved by the CommandService
because its responsibility must be to define operations that change the internal Aggregate
instances in some sense.
By cons, the QueryService
must not use the Aggregate
class directly, so it cannot change its state, but it’s able to know that state through the domain events given as AggregateDiff
instances. In this way, the QueryService
is able to have a custom internal representation of the Aggregate
oriented to the queries that it implements, being easier to have big performance advantages. A more detailed explanation can be seen in Exposing Information: Query Service.
Summary¶
After being described step by step the main features of the minos.cqrs.CommandService
class and related classes like minos.networks.Request
, minos.networks.Response
and minos.networks.enroute
, here is a full snapshot of the resulting src/commands.py
file:
"""src/commands.py"""
from minos.common import (
Condition,
EntitySet,
ValueObjectSet,
MinosSnapshotAggregateNotFoundException,
MinosSnapshotDeletedAggregateException,
)
from minos.cqrs import (
CommandService,
)
from minos.networks import (
Request,
Response,
ResponseException,
enroute,
)
from .aggregates import (
Exam,
Question,
Answer,
)
class ExamCommandService(CommandService):
@enroute.rest.command("/exams", "POST")
@enroute.broker.command("CreateExam")
async def create_exam(self, request: Request) -> Response:
content = await request.content()
exam = await Exam.create(content["name"], content["duration"], content["subject"], EntitySet())
return Response(exam.uuid)
@enroute.rest.command("/exams/{uuid}/questions", "POST")
@enroute.broker.command("AddExamQuestion")
async def add_question(self, request: Request) -> Response:
content = await request.content()
exam = await Exam.get(content["exam"])
answers = ValueObjectSet([Answer(raw["text"], raw["correct"]) for raw in content["answer"]])
question = Question(content["title"], answers)
exam.questions.add(question)
await exam.save()
return Response(question.uuid)
@enroute.rest.command("/exams", "DELETE")
@enroute.broker.command("DeleteExam")
async def delete_exam(self, request: Request) -> None:
content = await request.content()
uuid = content["uuid"]
try:
exam = await Exam.get(uuid)
except MinosSnapshotAggregateNotFoundException:
raise ResponseException(f"The exam does not exists: {uuid}")
except MinosSnapshotDeletedAggregateException:
raise ResponseException(f"The exam is already deleted: {uuid}")
await exam.delete()
@enroute.broker.event("SubjectDeleted")
async def delete_exam_by_subject(self, request: Request) -> None:
content = await request.content()
exams = {exam async for exam in await Exam.find(Condition("subject", content["uuid"]))}
for exam in exams:
await exam.delete()