The 'Rocky River'


How to architect your Django monolith
 

David Seddon

slides.com/davidseddon/rocky-river

 

This talk

  1. What is the Rocky River?
  2. Real world example

Rivers

A river system

django

django-treebeard

django-cms

bcrypt

django-formtools

openid

django-allauth

myproject.app3

myproject.app2

myproject.app1

myproject.app4

settings

You can think of your dependencies like a river system

Rivers only flow in one direction.

So should your dependencies.

upstream

downstream

django

django-treebeard

django-cms

bcrypt

django-formtools

openid

django-allauth

myproject.app2

myproject.app1

myproject.core

myproject.app3

myproject.main

settings

Rocks (#1)

Rocks are easier to handle when they're broken apart

So are apps.

App

App

App

App

App

App

App

Rocks (#2)

Rock is layered

Open layers

Closed layers

Typical layers in a Django app

Routing

Controller

Input validation

Business logic & storage

urls.py
views.py
forms.py
models.py

Splitting up storage and business logic

Routing

Controller

Input validation

Storage

urls.py
views.py
forms.py
models.py

Business logic

domain.py

Use layers to manage complexity

The Rocky River

  • No circular dependencies
  • Small apps
  • Layers within apps

The Rocky River in practice

The classic way to do it

Businesses

 

Accounting

 

 

 

 

Business

urls.py
views.py
forms.py
models.py
client.py

Designing a 'rocky river':

  1. Break things apart into manageable chunks.
  2. Decide on a dependency flow.
  3. Use layers to manage complexity.

Quickbooks

Sage

Xero

1. Break things apart into manageable chunks

Business

Cloudconnect

Quickbooks

Sage

Xero

Platform

1. Break things apart into manageable chunks

Business

Cloudconnect

Quickbooks

Sage

Xero

Platform

ConnectionService

1. Break things apart into manageable chunks

Business

Cloudconnect

Quickbooks

Sage

Xero

Platform

ConnectionService

 

 

 

Cloudconnect

Accounting

 

 

 

 

 

Xero

 

 

 

Quickbooks

 

 

 

Sage

1. Break things apart into manageable chunks

Business

Cloudconnect

2. Decide on a dependency flow

But how?

Business

upstream

downstream

2. Decide on a dependency flow

Dependency inversion principle

 

Abstractions should not depend on details.
Details should depend on abstractions.

Abstractions

Implementation details

upstream

downstream

From the SOLID principles:

Business

Platform

ConnectionService

Accounting

 

 

Abstractions

upstream

downstream

2. Decide on a dependency flow

Business

Quickbooks

Sage

Xero

Platform

ConnectionService

Cloudconnect

 

 

 

Cloudconnect

Accounting

 

 

 

 

 

Xero

 

 

 

Quickbooks

 

 

 

Sage

Abstractions

Implementation
details

upstream

downstream

2. Decide on a dependency flow

3. Use layers to manage complexity

Closed layer

Plumbing

Low level

urls.py
views.py
models.py
client.py

Domain

platform.py
connection_service.py

3. Use layers to manage complexity

Plumbing

Low level

Domain

Low level

Dependency injection

platform.py
views.py
urls.py
connection_service.py
 


 accounting
platform.py
views.py
urls.py
connection_service.py
platforms.py

 quickbooks

platforms.py
 sage
platforms.py

 xero

platform.py
urls.py
connection_service.py
 cloudconnect

 

 

client.py
models.py
connection_service.py
platform.py
urls.py
connection_service.py
 templates/accounting

 

 

connection_page.py
includes/base_connect_platform_block.html
templatetags/accounting_tags.py
includes/connect_xero_block.html
includes/connect_quickbooks_block.html
includes/connect_sage_block.html

Domain

}

Plumbing

}

Presentation

Low level

Low level

Into the code...

  1. Abstracting the problem
  2. Plumbing
  3. Presentation
  4. Implementing the solution

1. Abstracting the problem

# accounting/platform.html

class Platform:
    def __init__(self, name, verbose_name):
        self.name = name
        self.verbose_name = verbose_name

    def __str__(self):
        return self.verbose_name


class PlatformRegistry:
    def register(self, platform):
        ...

    def get_by_name(self, platform_name):
        ...

    def all(self):
        ...
# accounting/__init__.py
from django.utils.module_loading import autodiscover_modules


class AppConfig(BaseAppConfig):
    ...
    def ready(self):
        # Autodiscover any app modules named accounting_platforms.py
        autodiscover_modules('accounting_platforms')
...
# accounting/connection.html
from django.conf import settings
from django.utils.module_loading import import_string


class AbstractConnectionService:
    @classmethod
    def get_connected_platform(cls, business):
        """Given a business, returns the platform that the business
        is currently connected to. Otherwise returns None.
        """
        raise NotImplementedError

    @classmethod
    def get_connection_url(cls, business, platform):
        """Get the URL that the business should visit to connect
        to the supplied platform.
        """
        raise NotImplementedError

    @classmethod
    def store_successful_connection(cls, business, params):
        """Stores the connection following a successful connection
        using the connection service.
        """
        raise NotImplementedError


def get_connection_service():
    return import_string(settings.ACCOUNTING_CONNECTION_SERVICE)

2. Plumbing

# accounting/views.py
from . import platforms
from .connection_service import get_connection_service


class ConnectionsPage(TemplateView):
    """View for the user to view their connected platform,
    or select one to connect to.
    """
    template_name = 'accounting/connections.html'

    def get_context_data(self, *args, **kwargs):
        context_data = super().get_context_data(*args, **kwargs)

        connection_service = get_connection_service()

        # If the business is connected to a platform,
        # include that in the context
        context_data['connected_platform'] = connection_service\
            .get_connected_platform(self.business)

        # Otherwise, include all available platforms
        if not context_data['connected_platform']:
            context_data['available_platforms'] = platforms.all()

        return context_data

3. Presentation

# templates/accounting/connections.html

{% load accounting_tags %}
...

{% if connected_platform %}
    {% borrower_connected_platform_block connected_platform %}
{% else %}
    {% for platform in available_platforms %}
        {% borrower_connect_platform_block platform %}
    {% endfor %}
{% endif %}
...
# accounting/templatetags/accounting_tags.html
...

@register.simple_tag
def borrower_connect_platform_block(platform):
    """Display the block to allow the user to connect to a platform.
    """
    template_name = 'accounting/includes/connect_{}_block.html'/
        .format(platform.name)
    template = loader.get_template(template_name)
    return template.render({'platform': platform})

4. Implementing the solution

# xero/accounting_platforms.py

from accounting import platforms, Platform

xero = Platform(name='xero', verbose_name='Xero')
platforms.register(xero)
# quickbooks/accounting_platforms.py

from accounting import platforms, Platform

quickbooks = Platform(name='quickbooks', verbose_name='Quickbooks')
platforms.register(quickbooks)
# sage/accounting_platforms.py

from accounting import platforms, Platform

sage = Platform(name='sage', verbose_name='Sage')
platforms.register(sage)
# cloudconnect/connection_service.py

from accounting.connection import AbstractConnectionService
from accounting import platforms
from .models import CloudconnectBusinessConnection


class Cloudconnect(AbstractConnectionService):
    @classmethod
    def get_connected_platform(cls, business):
        try:
            connection = CloudconnectBusinessConnection.objects.filter(
                business=business).exclude(platform_name='').get()
        except CloudconnectBusinessConnection.DoesNotExist:
            return None
        else:
            return platforms.get_by_name(connection.platform_name)

    ...
# settings.py

ACCOUNTING_CONNECTION_SERVICE = \
    'cloudconnect.connection_service.Cloudconnect'
platform.py
views.py
urls.py
connection_service.py
 


 accounting
platform.py
views.py
urls.py
connection_service.py
platforms.py

 quickbooks

platforms.py
 sage
platforms.py

 xero

platform.py
urls.py
connection_service.py
 cloudconnect

 

 

client.py
models.py
connection_service.py
platform.py
urls.py
connection_service.py
 templates/accounting

 

 

connection_page.py
includes/base_connect_platform_block.html
templatetags/accounting_tags.py
includes/connect_xero_block.html
includes/connect_quickbooks_block.html
includes/connect_sage_block.html

Domain

}

Plumbing

}

Presentation

Low level

Low level

The Rocky River

  • No circular dependencies
  • Small apps
  • Layers within apps

David Seddon

@seddonym

david@seddonym.me

The Rocky River

By David Seddon

The Rocky River

  • 2,658