Domain Driven Design Toolkit

Artem Malyshev

@proofit404

BIO

pros

  1. Relatively easy to read

cons

  1. Really hard to change
  2. We can not see the whole picture

Implicit API

class Purchases(viewsets.ModelViewSet):
    queryset = Purchase.objects.all()
    serializer_class = PurchaseSerializer
    permission_classes = (CanPurchase,)
    filter_class = PurchaseFilter
router.register('/api/purchases/', Purchases)
  1. What exactly does this class do?
  2. How to use it?

Framework internals leak

class SubscriptionSerializer(Serializer):
    category_id = IntegerField()
    price_id = IntegerField()
def recreate_nested_writable_fields(self, instance):
    for field, values in self.writable_fields_to_recreate():
        related_manager = getattr(instance, field)
        related_manager.all().delete()
        for data in values:
            obj = related_manager.model.objects.create(
                to=instance, **data)
            related_manager.add(obj)

pros

  1. Relatively easy to change

cons

  1. Really hard to read
  2. You should keep in mind framework rules
  3. Implicit knowledge grow
  4. We still can not see the whole picture

If your code is crap, stickies on the wall won't help.

@HenrikKniberg

Code is...

  1. Fragile
  2. Hard to reason about
  3. Time-consuming

This is a common issue with any framework

Project layout

project
├── api
├── db
├── forms
├── migrations
├── permissions
├── serializers
├── templates
└── views

Complexity

Accidental complexity refers to challenges that developers unintentionally make for themselves as a result of trying to solve a problem.

Essential complexity is just the nature of the beast you're trying to tame.

Accidental complexity

  • AsyncIO vs. Gevent
  • PostgreSQL vs. MongoDB
  • Python vs. Go
  • Emacs vs. Vim
  • Tabs vs. Spaces

What is the domain-driven design?

Focus on the core complexity and opportunity in the domain

Explore models in a collaboration of domain experts and software experts

Write software that expresses those models explicitly

Speak ubiquitous language within a bounded context

What is a model?

A set of entities, services and value objects expressed in classes, methods, and refs.

dry-python

A set of libraries for pluggable business logic components.

Project layout

project
├── api
├── db
├── forms
├── migrations
├── permissions
├── serializers
├── templates
└── views

Project layout

project
├── api
├── db
├── entities
├── forms
├── migrations
├── serializers
├── templates
└── views

Entities

from attr import attrs
from typing import List, NewType

OrderId = NewType("OrderId", int)

@attrs
class Order:
    primary_key: OrderId
    items: List[Item]

    def could_be_processed(self):
        return all(item.is_available for item in self.items)

pros

  1. Single responsibility
  2. Interface segregation
  3. Testable

cons

  1. Working with any data store manually

Define a user story in the business transaction DSL.

Separate state, implementation and specification.

Project layout

project
├── api
├── db
├── entities
├── forms
├── migrations
├── serializers
├── templates
├── usecases
└── views

Specification DSL

from stories import story, arguments

class Purchase:
    @story
    @arguments("invoice_id", "user")
    def make(I):
        I.find_order
        I.find_price
        I.find_invoice
        I.check_balance
        I.persist_payment
        I.persist_purchase
        I.send_purchase_notification

Steps implementation

from stories import Failure, Success

class Purchase:
    @story
    def make(I): ...

    def find_invoice(self, ctx):
        ctx.invoice = self.load_invoice(ctx.invoice_id)
        return Success()

    def check_balance(self, ctx):
        if ctx.user.can_pay(ctx.invoice): ...

    load_invoice: Callable

Story Execution

>>> purchase = Purchase(load_invoice=lambda invoice_id: ...)
>>> purchase.make(category_id=2, user=...)
Purchase.make:
  find_order
  find_price
  check_purchase (PromoCode.validate)
    find_code (skipped)
  check_balance
    find_profile
Context:
  category_id = 1318  # Story argument
  user = <User: 3292> # Story argument
  category = <Category: 1318>
    # Set by Purchase.find_category

pros

  1. Clean flow in the source code
  2. Separate step implementation
  3. Each step knows nothing about a neighbor
  4. Easy reuse of code
  5. Allows to instrument code easily

DEBUG TOOLBAR











py.test

Sentry

ELK

State Contract

from pydantic import BaseModel, validator

class Purchase:
    @story
    def make(I): ...

@Purchase.make.contract
class Context(BaseModel):
    user: User
    order: Optional[Order]

    @validator("order")
    def order_expectations(cls, order):
        return order.could_be_processed()

pros

  1. Explicit data contracts and relations in code
  2. Data store independent
  3. Catch errors when they occur
  4. Not when they propagate to exception

Declarative mappers from ORM models to domain entities. And back again!

Project layout

project
├── api
├── db
├── entities
├── forms
├── migrations
├── repositories
├── serializers
├── templates
├── usecases
└── views

Django ORM

from mappers import Mapper
from app.entities import Order, OrderId, User
from app.db import OrderTable, UserTable

mapper = Mapper(Order, OrderTable, {"primary_key": "id"})

@mapper.reader
def load_order(id: OrderId, user: User) -> Order:
    friends = UserTable.objects.filter(
        purchases=OuterRef("pk"), friends=user.primary_key)
    return OrderTable.objects.filter(pk=id).annotate(
        purchased_by_friends=Exists(friends)).get()

Swagger definitions

from mappers import Mapper
from bravado import swagger_model
from app.entities import Price

spec = swagger_model.load_file("price_service.yml")
mapper = Mapper(Price, spec.definitions["Price"])

@mapper.reader
def load_price(id: PriceId) -> Price:
    return requests.get(f"http://172.16.1.7/get/{id}")

GraphQL queries

from mappers import Mapper
from gql import gql, Client, build_schema
from app.entities import Invoice

schema = build_schema("invoice_service.graphql")
mapper = Mapper(Invoice, schema.get_type_map()["Invoice"])

@mapper.reader
def load_invoice(id: InvoiceId) -> Invoice:
    return Client(schema=schema).execute(gql("""
      {
        loadInvoice(id: %(id)d)
      }
    """, {"id": id}))

pros

  1. Automated work with data stores
  2. Prevent serialization process leak into entities

Provide composition instead of inheritance.

Solves top-down approach problem.

Injection

from dependencies import Injector, Package

app = Package('app')

class MakePurchase(Injector):

    make_purchase = app.services.Purchase.make
    load_order = app.repositories.load_order
    load_price = app.repositories.load_price
    load_invoice = app.repositories.load_invoice

MakePurchase.make_purchase(category_id=1, price_id=1)

pros

  1. Boilerplate-free object hierarchies
  2. API entrypoints, admin panels, CLI commands are oneliners
  • We didn't have the tooling to work with data
  • There were no data contracts written in code

How we use third-party libraries

from pusher import Pusher

class Purchase:
    def send_purchase_notification(self, ctx):
        Pusher().trigger("private-user-1")
def test_before(monkeypatch):
    monkeypatch.setattr(pusher, "Pusher", Mock())
    # ...
    pusher.Pusher.trigger.assert_called_once_with(
        "private-user-1"
    )

How to use it with DI

@attrs
class Purchase:
    def send_purchase_notification(ctx):
        self.trigger_message(UserStream(ctx.user))

    trigger_message: Callable
def test_after(emitter):
    # ...
    Purchase.trigger_message.assert_called_once_with(
        UserStream(User(primary_key=1))
    )

Get in touch