├── .circleci └── config.yml ├── .github └── ISSUE_TEMPLATE │ └── feedback-template.md ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── diagrams ├── dads_logo.png ├── dads_main.png └── two_dads.png ├── docs ├── README.md ├── appendix.md ├── contributing.md ├── domains.md ├── examples.md ├── files.md ├── plugins.md ├── styleguide.md ├── testing.md └── using.md ├── example_domain ├── __init__.py ├── admin.py ├── apis.py ├── apps.py ├── interfaces.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py └── services.py ├── mkdocs.yml └── requirements.txt /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build_docs: 4 | docker: 5 | - image: circleci/python:3.7 6 | steps: 7 | - checkout 8 | - run: 9 | name: Build Dependencies 10 | command: | 11 | python3 -m venv venv 12 | . venv/bin/activate 13 | pip install -r requirements.txt 14 | - run: 15 | name: Build and publish documentation 16 | command: | 17 | . venv/bin/activate 18 | mkdocs gh-deploy 19 | workflows: 20 | version: 2 21 | build: 22 | jobs: 23 | - build_docs: 24 | filters: 25 | branches: 26 | only: 27 | - master 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feedback-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feedback template 3 | about: Ask for feedback 4 | title: "[FEEDBACK]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Hello, and thanks for offering feedback on the Django API Domains styleguide. 11 | 12 | Please articulate your feedback in a clear language and offer suggestions for improvements, rather than just criticisms. 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *site/* 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 1.2.1 | 25-09-2019 2 | ================ 3 | 4 | - Fixed a lot of grammar issues. 5 | - Fixed some styling stuff to make things easier to read. 6 | - A note on shall methods / pass-through methods code-smell. 7 | - A note on wording. 8 | 9 | Contributors: 10 | 11 | - Paul Hallett (Author) 12 | 13 | 1.2 | 10-06-2019 14 | ================ 15 | 16 | - Styleguide is now published at [https://phalt.github.io/django-api-domains](https://phalt.github.io/django-api-domains). Going forward, this is the primary format for the styleguide. markdown versions are viewable at the docs/ folder. 17 | - Relax ruling on JSON as the only serializable format (hello XML and gRPC!) 18 | - Introduce a recommended plugins section 19 | - Introduce a how-to-use-this-styleguide type section 20 | 21 | Contributors: 22 | 23 | - Paul Hallett (Author) 24 | 25 | 26 | 1.1 | 09-04-2019 27 | ================ 28 | 29 | - Adds clarification around which logic should live where 30 | - Adds clarification on absolute vs relative imports 31 | - General grammar, consistency fixes 32 | 33 | Contributors: 34 | 35 | - Paul Hallett (Author) 36 | - Emre Kartoglu (Reviewer, Advice) 37 | - Will Earp (Advice) 38 | 39 | 1.0 | 01-02-2019 40 | ================ 41 | 42 | Initial version 43 | 44 | Contributors: 45 | 46 | - Paul Hallett (Author) 47 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at paulandrewhallett@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Paul Hallett 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ⚠️ [Currently stable and unlikely to change much, provide feedback here](https://github.com/phalt/django-api-domains/issues/31) ⚠️ 2 | 3 | ### Check out my new project for OpenAPI Clients: [Clientele](https://github.com/phalt/clientele) 4 | 5 | ![logo.png](diagrams/dads_logo.png) 6 | 7 | # Django API Domains 8 | _Style guides for the API age_ 9 | 10 | | Version | Author(s) | Date | 11 | | ----------------------------------------------------------------------- |-------------------------------------------|------------| 12 | | [1.2.1](https://github.com/phalt/django-api-domains/releases/tag/1.2.1) | Paul Hallett paulandrewhallett@gmail.com | 25-09-2019 | 13 | | [1.2](https://github.com/phalt/django-api-domains/releases/tag/1.2) | Paul Hallett paulandrewhallett@gmail.com | 10-06-2019 | 14 | | [1.1](https://github.com/phalt/django-api-domains/releases/tag/1.1) | Paul Hallett paulandrewhallett@gmail.com | 09-04-2019 | 15 | | [1.0](https://github.com/phalt/django-api-domains/releases/tag/1.0) | Paul Hallett paulandrewhallett@gmail.com | 01-02-2019 | 16 | 17 | ## Introduction 18 | 19 | This styleguide combines [domain-driven design](https://en.wikipedia.org/wiki/Domain-driven_design) principles and Django's [apps](https://docs.djangoproject.com/en/dev/ref/applications/#module-django.apps) pattern to provide a **pragmatic** guide for developing scalable API services with the Django web framework. 20 | 21 | This styleguide tries to tackle two big problems: 22 | 23 | 1) Design philosophies and design patterns work in "ideal" situations, and most real life problems do not represent this ideal world. Therefore we need to develop a flexible pattern that can adjust to support different situations. 24 | 2) The original design and documentation of Django is geared heavily towards server-side-rendered-view applications, yet most modern Django applications are built to serve APIs for a separate frontend application. Therefore, Django's patterns are outdated for today's trends. 25 | 26 | In order to overcome these problems, this styleguide tries to achieve the following five goals: 27 | 28 | 1) Treat Django's `apps` more like software `domains`. 29 | 2) Extend Django's `apps` implementation to support strong [bounded context](https://www.martinfowler.com/bliki/BoundedContext.html) patterns between `domains`. 30 | 3) Enable separation of domains to happen when it makes sense for **increased development velocity**, not just for **business value**. 31 | 4) Design a styleguide that reduces the effort involved in extracting the code for large domains into separate application servers. 32 | 5) Make sure the styleguide compliments API-based applications. 33 | 34 | # Read the styleguide 35 | 36 | The styleguide is now published as a readable documentation site. You can view it at [https://phalt.github.io/django-api-domains/](https://phalt.github.io/django-api-domains/) or view the [docs](docs/) folder directly. 37 | -------------------------------------------------------------------------------- /diagrams/dads_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/django-api-domains/72575498aae5475e60b9568aee2cae257df80405/diagrams/dads_logo.png -------------------------------------------------------------------------------- /diagrams/dads_main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/django-api-domains/72575498aae5475e60b9568aee2cae257df80405/diagrams/dads_main.png -------------------------------------------------------------------------------- /diagrams/two_dads.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/django-api-domains/72575498aae5475e60b9568aee2cae257df80405/diagrams/two_dads.png -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ![Django API Domains image](https://repository-images.githubusercontent.com/164852158/ce0fb480-61c8-11e9-933b-60fa95d9e435) 4 | 5 | This styleguide combines [domain-driven design](https://en.wikipedia.org/wiki/Domain-driven_design) principles and Django's [apps](https://docs.djangoproject.com/en/dev/ref/applications/#module-django.apps) pattern to provide a **pragmatic** guide for developing scalable API services with the Django web framework. 6 | 7 | This styleguide tries to tackle two big problems: 8 | 9 | 1. Design philosophies and design patterns work in "ideal" situations, and most real life problems do not represent this ideal world. Therefore we need to develop a flexible pattern that can adjust to support different situations. 10 | 2. The original design and documentation of Django is geared heavily towards server-side-rendered-view applications, yet most modern Django applications are built to serve APIs for a separate frontend application. Therefore, Django's patterns are outdated for today's trends. 11 | 12 | In order to overcome these problems, this styleguide tries to achieve the following five goals: 13 | 14 | 1. Treat Django's `apps` like software `domains`. 15 | 2. Extend Django's `apps` implementation to support strong [bounded context](https://www.martinfowler.com/bliki/BoundedContext.html) patterns between `domains`. 16 | 3. Enable separation of domains to happen when it makes sense for **increased development velocity**, not just for **business value**. 17 | 4. Design a styleguide that reduces the effort involved in extracting the code for large domains into separate application servers. 18 | 5. Make sure the styleguide compliments API-based applications. 19 | 20 | ## Current Version 21 | 22 | **CURRENT VERSION: [VERSION 1.2.1](https://github.com/phalt/django-api-domains/releases/tag/1.2.1)** 23 | 24 | ## Previous Versions 25 | 26 | All previous versions can be found under the `docs/` folder when looking at a [specific tag](https://github.com/phalt/django-api-domains/releases). 27 | 28 | ## CHANGELOG 29 | 30 | Please see [CHANGELOG.md](https://github.com/phalt/django-api-domains/blob/master/CHANGELOG.md) 31 | -------------------------------------------------------------------------------- /docs/appendix.md: -------------------------------------------------------------------------------- 1 | # Appendix 2 | 3 | This document's use of **CAN**, **SHOULD** and **MUST** are based on RFC 2119: [https://www.ietf.org/rfc/rfc2119.txt](https://www.ietf.org/rfc/rfc2119.txt) 4 | 5 | # Acknowledgements 6 | 7 | This styleguide is heavily inspired by: 8 | 9 | * Django's own documentation and recommendations: [https://docs.djangoproject.com/en/dev/](https://docs.djangoproject.com/en/dev/) 10 | * The HackSoftware Django-styleguide: [https://github.com/HackSoftware/Django-Styleguide](https://github.com/HackSoftware/Django-Styleguide) 11 | * Model-driven engineering: [https://en.wikipedia.org/wiki/Model-driven_engineering](https://en.wikipedia.org/wiki/Model-driven_engineering) -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Found an issue? 2 | 3 | Please [file an issue here](https://github.com/phalt/django-api-domains/issues). Please make sure the issue has not already be raised - search for it first. 4 | 5 | Please read the [code of conduct](https://github.com/phalt/django-api-domains/blob/master/CODE_OF_CONDUCT.md) when contributing to the project. -------------------------------------------------------------------------------- /docs/domains.md: -------------------------------------------------------------------------------- 1 | # Domains 2 | 3 | A [domain](https://en.wikipedia.org/wiki/Domain_(software_engineering)) is a piece of software that provides a distinct **business** value for your application. 4 | 5 | What this styleguide calls a `domain` is roughly an extension of what Django would call an `app`. Therefore a business domain **should** have at least one distinct software domain mirroring it. 6 | 7 | --- 8 | 9 | > ### Examples in this guide 10 | 11 | > The examples in this guide will talk about a `book shop` that shares details about books. 12 | > This can be modelled as a _business domain_ called `books`, and as a _software domain_ also called `books`. 13 | 14 | --- 15 | 16 | This guide tries to keep the key benefits of Django's `app` pattern - namely Django's [models](https://docs.djangoproject.com/en/2.1/topics/db/models/) to represent tables in a datastore, but with an emphasis on **skinny models**. 17 | 18 | This guide also retains Django's ability to *package apps as installable components in other applications*. This allows domains to be easily migrated to different codebases or completely different projects. 19 | 20 | ## Domain rules 21 | 22 | There are two major rules around domains: 23 | 24 | 1. You **should** split a domain if it becomes too big to work on. 25 | 26 | A domain should allow between 4-6 developers (3 pairs) to comfortably work on it. If you find your developers being blocked by each other then it is time to consider splitting the domain or checking whether the software has not diverged too far from the styleguide. 27 | 28 | --- 29 | 30 | 2. You **should** adhere to the styleguide patterns in this document in order to maintain strong bounded contexts between your domains. 31 | 32 | This applies even in situations where you extract one domain into two domains to increase velocity, but they still have to maintain a dependency between one another. We have found that if you relax the bounded context between domains, the boundary will erode and you will lose the ability to work on them independent of each other. 33 | 34 | 35 | --- 36 | 37 | > ### Protip 38 | > An example _software_ domain is provided in the same directory as this styleguide under [example_domain/](https://github.com/phalt/django-api-domains/tree/master/example_domain). 39 | 40 | --- 41 | 42 | Next, we will discuss the styleguide in detail. 43 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | ## Two domains 2 | 3 | Let's start with a basic example - a project that has two domains. They both expose APIs for a Frontend service, and they also communicate to each other. 4 | Communicating to the Frontend service is simple - they have defined a REST API in their API layer and the Frontend calls it. 5 | 6 | When talking to each other, they both _interface_ (hence the naming choice) with each other through the appropriate layers. Sometimes we call this the "API to interface path". 7 | 8 | Let's imagine that the owners of Domain B decide they want to move their software into another web server on the other side of the world. The first thing they might do is make a copy of the software of Domain B. They might spin that copy up in the new world, and slowly port the data over from the old Domain B. 9 | 10 | During this time, Domain A is still talking to the old Domain B. When the new Domain B is ready to go, they inform the team that owns Domain A. The Domain A team will then update their interfaces.py to point to the new Domain B instance. Because they have not leaked Domain B's logic throughout Domain A, the change is isolated to a single file (and hopefully a single git commit). 11 | 12 | ![two-domains](https://raw.githubusercontent.com/phalt/django-api-domains/master/diagrams/two_dads.png) 13 | 14 | More examples to come in the future. -------------------------------------------------------------------------------- /docs/files.md: -------------------------------------------------------------------------------- 1 | 2 | ### Examples on this page 3 | 4 | In the examples below we imagine a service with two domains - one for books, and one for authors. The abstraction between books and authors is only present to demonstrate the concepts in the styleguide. You could argue that Books and Authors can live in one domain. In our example **we also assume that a book can only have one author.** It's a strange world. 5 | 6 | ## Models 7 | 8 | _Models_ defines how a data model / database table looks. This is a Django convention that remains mostly unchanged. The key difference here is that you use _skinny models_. No complex functional logic should live here. 9 | 10 | In the past Django has recommended an [active record](https://docs.djangoproject.com/en/2.1/misc/design-philosophies/#models) style for its models. In practice, we have found that this encourages developers to bloat `models.py`, making it do too much and often binding the presentation and functional logic of a domain too tightly. This makes it very hard to have abstract presentations of the data in a domain. Putting all the logic in one place also makes it difficult to scale the number of developers working in this part of the codebase. See the [_"Which logic lives where?"_](/styleguide/#which-logic-lives-where) section for clarification. 11 | 12 | A models.py file can look like: 13 | 14 | ```python 15 | import uuid 16 | from django.db import models 17 | 18 | 19 | class Book(models.Model): 20 | 21 | id = models.UUIDField(primary_key=True, default=uuid.uuid4) 22 | name = models.CharField(max_length=256) 23 | publisher = models.CharField(max_length=256) 24 | author_id = models.UUIDField(default=uuid.uuid4) 25 | 26 | @property 27 | def name_and_publisher(self): 28 | return f'{self.name}, {self.publisher}' 29 | 30 | ``` 31 | 32 | - Models **must not** have any complex functional logic in them. 33 | - Models **should** own informational logic related to them. 34 | - Models **can** have computed properties where it makes sense. 35 | - Models **must not** import services, interfaces, or apis from their own domain or other domains. 36 | - Table dependencies (such as ForeignKeys) **must not** exist across domains. Use a UUID field instead, and have your Services control the relationship between models. 37 | - You **can** use ForeignKeys between tables in one domain. (But be aware that this might hinder future refactoring.) 38 | 39 | 40 | ## APIs 41 | 42 | _APIs_ defines the external API interface for your domain. Anyone using the APIs defined here is called a _consumer_. The API can be either an HTTP API using [GraphQL](https://github.com/graphql-python) or [REST](https://www.django-rest-framework.org/) for consumers over the web, or a software API for internal consumers. APIs is defined in `apis.py` which is agnostic to the implementation you choose, you can even put more than one API in a domain. For example - you might want to wrap a GraphQL API _and_ a REST API around your domain for different consumers. 43 | 44 | An `apis.py` file that defines a simple software API can look like: 45 | 46 | ```python 47 | import logging 48 | import uuid 49 | from typing import Dict # noqa 50 | 51 | from .services import BookService 52 | 53 | logger = logging.getLogger(__name__) 54 | 55 | 56 | class BookAPI: 57 | 58 | @staticmethod 59 | def get(*, book_id: uuid.UUID) -> Dict: 60 | logger.info('method "get" called') 61 | return BookService.get_book(id=book_id) 62 | 63 | ``` 64 | 65 | - APIs **must be** used as the entry point for all other consumers who wish to use this domain. 66 | - APIs **should** own presentational logic and schema declarations. 67 | - Internal domain-to-domain APIs **should** just be functions. 68 | - You **can** group internal API functions under a class if it makes sense for organisation. 69 | - If you are using a class for your internal APIs, it **must** use the naming convention `MyDomainAPI`. 70 | - Internal functions in APIs **must** use type annotations. 71 | - Internal functions in APIs **must** use keyword arguments. 72 | - You **should** log API function calls. 73 | - All data returned from APIs **must be** serializable. 74 | - APIs **must** talk to Services to get data. 75 | - APIs **must not** talk to Models directly. 76 | - APIs **should** do simple logic like transforming data for the outside world, or taking external data and transforming it for the domain to understand. 77 | - Objects represented through APIs **do not** have to map directly to internal database representations of data. 78 | 79 | 80 | ## Interfaces 81 | 82 | Your domain may need to communicate with another domain. That domain can be in another web server across the web, or it could be within the same server. It could even be a third-party service. When your domain needs to talk to other domains, you should define **all interactions to the other domain in the `interfaces.py` file**. Combined with APIs (see above), this forms the bounded context of the domain and prevents domain logic from leaking in. 83 | 84 | Consider `interfaces.py` like a mini _Anti-Corruption Layer_. Most of the time it won't change and it'll just pass on arguments to an API function. But when the other domain moves - say you extract it into its own web service, your domain only needs to update the code in `interfaces.py` to reflect the change. No complex refactoring needed, woohoo! 85 | 86 | It's worth noting that some guides would consider this implementation a 'code smell' because it has the potential for creating shallow methods or pass-through methods. This is somewhat true, and leads us back to the [pragmatism](/django-api-domains/using/#be-pragmatic) point in our guide. If you find your `interfaces.py` is redundant, then you probably don't need it. That said: **we recommend starting with it and removing it later**. 87 | 88 | An `interfaces.py` may look like: 89 | 90 | ```python 91 | import uuid 92 | from typing import Dict, Str # noqa 93 | 94 | # Could be an internal domain or an HTTP API client - we don't care! 95 | from src.authors.apis import AuthorAPI 96 | 97 | 98 | # plain example 99 | def update_author_name(*, author_name: Str, author_id: uuid.UUID) -> None: 100 | AuthorAPI.update_author_name( 101 | id=author_id, 102 | name=author_name, 103 | ) 104 | 105 | 106 | # class example 107 | class AuthorInterface: 108 | 109 | @staticmethod 110 | def get_author(*, id: uuid.UUID) -> Dict: 111 | return AuthorAPI.get(id=id) 112 | 113 | @staticmethod 114 | def update_author_name( 115 | *, 116 | author_name: Str, 117 | author_id: uuid.UUID, 118 | ) -> None: 119 | AuthorAPI.update_author_name( 120 | id=author_id, 121 | name=author_name, 122 | ) 123 | 124 | ``` 125 | 126 | - The primary components of Interfaces **should** be functions. 127 | - You **can** group functions under a class if it makes sense for organisation. 128 | - If you are using a class, it **must** use the naming convention `MyDomainInterface`. 129 | - Functions in Interfaces **must** use type annotations. 130 | - Functions in Interfaces **must** use keyword arguments. 131 | 132 | ## Services 133 | 134 | Everything in a domain comes together in Services. 135 | 136 | _Services_ gather all the business value for this domain. What type of logic should live here? Here are a few examples: 137 | 138 | - When creating a new instance of a model, we need to compute a field on it before saving. 139 | - When querying some content, we need to collect it from a few different places and gather it together in a python object. 140 | - When deleting an instance we need to send a signal to another domain so it can do it's own logic. 141 | 142 | Anything that is specific to the domain problem and **not** basic informational logic should live in Services. As most API projects expose single functional actions such as Create, Read, Update, and Delete, _Services_ has been designed specifically to compliment stateless, single-action functions. 143 | 144 | A `services.py` file could look like: 145 | 146 | ```python 147 | import logging 148 | import uuid 149 | from typing import Dict, Str # noqa 150 | 151 | from .interfaces import AuthorInterface 152 | from .models import Book 153 | 154 | logger = logging.getLogger(__name__) 155 | 156 | 157 | # Plain example 158 | def get_book(*, id: uuid.UUID) -> Dict: 159 | book = Book.objects.get(id=id) 160 | author = AuthorInterface.get_author(id=book.author_id) 161 | return { 162 | 'name': book.name, 163 | 'author_name': author.name, 164 | } 165 | 166 | 167 | # Class example 168 | class PGMNodeService: 169 | 170 | @staticmethod 171 | def get_book(*, id: uuid.UUID) -> Dict: 172 | book = Book.objects.get(id=id) 173 | author = AuthorInterface.get_author(id=book.author_id) 174 | return { 175 | 'name': book.name, 176 | 'author_name': author.name, 177 | } 178 | 179 | @staticmethod 180 | def create_book(*, name: Str, author_id: uuid.UUID) -> Dict: 181 | logger.info('Creating new book') 182 | new_book = Book.objects.create(name=name, author_id=author_id) 183 | author = AuthorInterface.get_author(id=new_book.author_id) 184 | return { 185 | 'name': new_book.name, 186 | 'author_name': author.name, 187 | } 188 | 189 | @staticmethod 190 | def update_book_name_and_author_name( 191 | *, 192 | name: Str, 193 | author_name: Str, 194 | author_id: uuid.UUID, 195 | id: uuid.UUID, 196 | ) -> Dict: 197 | logger.info('Updating book name and author name') 198 | book = Book.objects.get(id=id).update(name=name) 199 | author = AuthorInterface.update_author_name( 200 | name=author_name, id=author_id, 201 | ) 202 | return { 203 | 'name': book.name, 204 | 'author_name': author.name, 205 | } 206 | 207 | ``` 208 | 209 | - The primary components of Services **should** be functions. 210 | - Services **should** own co-ordination and transactional logic. 211 | - You **can** group functions under a class if it makes sense for organisation. 212 | - If you are using a class, it **must** use the naming convention `MyDomainService`. 213 | - Functions in services.py **must** use type annotations. 214 | - Functions in services.py **must** use keyword arguments. 215 | - You **should** be logging in `services.py`. 216 | -------------------------------------------------------------------------------- /docs/plugins.md: -------------------------------------------------------------------------------- 1 | We recommend the following plugins for use with this guide: 2 | 3 | ## Django REST Framework 4 | 5 | [Django REST Framework](https://www.django-rest-framework.org/) is the de facto framework for building REST API services with Django. 6 | 7 | When using DRF, we can organise the logic in a domain this way: 8 | 9 | - `urls.py` - Router and URL configuration. 10 | - `apis.py` - DRF view functions or view classes. 11 | - `serializers.py` - Serialization for models. 12 | 13 | Additional ruling for DRF: 14 | 15 | * You **should** serialize all models using DRF serializers. 16 | * You **should not** use the [ModelMixin](https://www.django-rest-framework.org/api-guide/generic-views/#mixins) Viewsets as they will tightly couple the data layer with the presentation layer. 17 | 18 | ## Graphene Django 19 | 20 | [Graphene-Django](https://docs.graphene-python.org/projects/django/en/latest/) is the recommended framework for creating [GraphQL](https://graphql.org/) APIs with Django. 21 | 22 | When using Graphen-Django, we can organise the logic in a domain this way: 23 | 24 | - `apis.py` - Queries and Mutations. 25 | 26 | Additional ruling for Graphene-Django: 27 | 28 | * You **should not** tightly link an `DjangoObjectType` to a Django model as this will tightly couple the data layer with the presentation layer. Instead, use a generic `ObjectType`. -------------------------------------------------------------------------------- /docs/styleguide.md: -------------------------------------------------------------------------------- 1 | ## Visualisation 2 | 3 | ![diagrams/dads_main.png](https://raw.githubusercontent.com/phalt/django-api-domains/master/diagrams/dads_main.png) 4 | 5 | ## File structure 6 | 7 | * A domain **must** use the following file structure: 8 | 9 | ``` 10 | - apis.py - Public functions and access points, presentation logic. 11 | - interfaces.py - Integrations with other domains or external services. 12 | - models.py - Object models and storage, simple information logic. 13 | - services.py - coordination and transactional logic. 14 | ``` 15 | 16 | In addition, any existing files from a standard Django app are still allowed, such as `urls.py`, `apps.py` and `migrations/*`. 17 | 18 | * `Views.py` in [Django's pattern](https://docs.djangoproject.com/en/dev/#the-view-layer) is **explicitly not allowed** in this styleguide. 19 | 20 | We only focus on API-based applications. Most logic that used to live in Django's `views.py` would now be separated into APIs and Services. 21 | 22 | * You **can** mask one of the required files as a directory for better file organisation. For example, you might want to split `apis.py` file into this structure: 23 | 24 | ``` 25 | apis/ 26 | __init__.py 27 | rest.py 28 | graphql.py 29 | ``` 30 | 31 | Your `__init__.py` file in this directory can import the local files: 32 | 33 | ```python 34 | # apis/__init__.py 35 | from .rest import * # noqa 36 | from .graphql import * # noqa 37 | ``` 38 | 39 | Then, any other file can import from apis like so: 40 | 41 | ```python 42 | # example.py 43 | from domain.apis import Foo 44 | ``` 45 | 46 | This keeps namespaces tidy and does not leak domain details. 47 | 48 | * A domain **does not** need to have all these files if it is not using them. For example - a domain that just coordinates API calls to other domains does not need to have Models as it is probably not storing anything in a datastore. 49 | 50 | * A domain **can have** additional files when it makes sense (such as `utils.py` or `enums.py` or `serializers.py`) to separate out parts of the code that aren't covered by the styleguide pattern. 51 | 52 | Read more about the files in the [files](/files) section. 53 | 54 | ## Absolute or Relative imports? 55 | 56 | The ruling for absolute or relative imports is as follows: 57 | 58 | * When importing files within a domain, you **must** use relative imports. 59 | * When importing other domains in the same project, you **must** use absolute imports. 60 | * When importing domains in tests, you **should** use absolute imports. 61 | * When importing third-party packages you **should** use absolute imports. 62 | 63 | TL;DR - relative imports inside a domain, absolute for everything else! 64 | 65 | With this ruling domains are easy to package and move around. When it comes time to move it into it's own project; tidying up imports will be one less thing you have to do. 66 | 67 | ## Which logic lives where? 68 | 69 | It's common in programming to end up confused about what type of logic should live where. 70 | 71 | There are many cases where it's difficult to decide, and the best advice is to **pick a pattern and stick to it**, but for simpler things, this guide emphasises the following: 72 | 73 | ### APIs 74 | Logic about presentation. 75 | 76 | --- 77 | 78 | > **If you ask:** 79 | > "Where should I show this data to the user?" or "Where do I define the API schema?" 80 | 81 | --- 82 | 83 | 84 | ### Services 85 | Logic around coordination and transactions. 86 | 87 | --- 88 | 89 | > **If you ask:** 90 | > "Where do I coordinate updating many models in one domain?" or "Where do I dispatch a single action out to other domains?" 91 | 92 | --- 93 | 94 | 95 | ### Models 96 | Logic around information. 97 | 98 | --- 99 | 100 | > **If you ask:** 101 | > "Where can I store this data?" or "Where can I do any post/pre-save actions?" 102 | 103 | --- 104 | 105 | 106 | ### Interfaces 107 | 108 | Logic for handling the transformation of data from other domains. 109 | 110 | --- 111 | 112 | > **If you ask:** 113 | > "Where shall I connect to another domain?" or "How do I change the data format for another domain?" 114 | 115 | --- 116 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | ## Testing boundaries? 2 | 3 | The only place in your code that touches the outside world (anything outside your domain - other domains, or external consumers) is `interfaces.py`. Any file that handles `interfaces.py` should **mock out other dependent domains** but you should still be testing your **own interface definitions**. 4 | 5 | You should use Python's [standard `patch` tool for this](https://docs.python.org/3.5/library/unittest.mock.html#unittest.mock.patch). 6 | 7 | You **can** use [`MagicMock`](https://docs.python.org/3.5/library/unittest.mock.html#unittest.mock.MagicMock) where it makes sense. 8 | 9 | Here is an example: 10 | 11 | ```python 12 | # book/test_services.py 13 | from unittest.mock import patch 14 | 15 | from src.book.services import create_book 16 | 17 | 18 | @patch('src.book.interfaces.AuthorInterface.get_author') 19 | def test_service_calls_author_interface(mocked_function_call): 20 | # Set up patched domain calls if you need it 21 | mocked_function_call.return_value = { 22 | 'returned': 'object', 23 | } 24 | # The actual test 25 | result = create_book( 26 | name='A Wizard of Earthsea', 27 | author_id='d29eee0b-5b60-46d8-8c42-a8da9ddabbb6', 28 | ) 29 | # Assert patched domain called with expected values 30 | assert mocked_function_call.assert_called_with( 31 | id='d29eee0b-5b60-46d8-8c42-a8da9ddabbb6', 32 | ) 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/using.md: -------------------------------------------------------------------------------- 1 | # Pin against a version 2 | 3 | In software we often specify a version of a project to use. For example - `requests==1.2` - this says _"we want to use requests version 1.2"_. 4 | 5 | For this guide we recommend the following: picking a specific version and working against it. Just like software; future versions of this guide could change, potentially causing conflicts with the styleguide in your project. 6 | 7 | You can see the released versions [here](https://github.com/phalt/django-api-domains/releases) and by clicking on the tag icon you can view historical versions. The published version at [https://phalt.github.io/django-api-domains](https://phalt.github.io/django-api-domains) will always be the latest version. 8 | 9 | # Be pragmatic 10 | 11 | Don't blindly follow what other people have written down. Sometimes it might not be suitable for your own problem. Use this guide as inspiration and guidance, but make your own decisions with your team when you aren't satisfied with it. 12 | 13 | --- 14 | 15 | > ### Protip 16 | > Keep track of any additional rules you make to keep your styleguide consistent! 17 | 18 | --- 19 | 20 | # Wording in this guide 21 | 22 | Sometimes this guide refers to the following areas in a domain: `interfaces`, `apis`, `services`, and `models`. You can assume any code examples are from the file name of the same area. E.g. - code examples under an `apis` heading is meant for the `apis.py` file. 23 | -------------------------------------------------------------------------------- /example_domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/django-api-domains/72575498aae5475e60b9568aee2cae257df80405/example_domain/__init__.py -------------------------------------------------------------------------------- /example_domain/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Book 4 | 5 | admin.site.register(Book) 6 | -------------------------------------------------------------------------------- /example_domain/apis.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import uuid 3 | from typing import Dict # noqa 4 | 5 | import graphene 6 | from graphene import ObjectType, relay 7 | 8 | from .services import BookService 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | # Internal API 14 | class BookAPI: 15 | 16 | @staticmethod 17 | def get(*, book_id: uuid.UUID) -> Dict: 18 | logger.info('method "get" called') 19 | return BookService.get_book(id=book_id) 20 | 21 | 22 | # graphQL Entity 23 | class BookEntity(ObjectType): 24 | class Meta: 25 | interfaces = (relay.Node,) 26 | name = graphene.String() 27 | author_name = graphene.String() 28 | 29 | 30 | # graphQL mutation 31 | class CreateBook(relay.ClientIDMutation): 32 | class Input: 33 | book_name = graphene.String(required=True) 34 | author_id = graphene.ID(required=True) 35 | 36 | book = graphene.Field(BookEntity) 37 | 38 | @classmethod 39 | def mutate_and_get_payload(cls, root, info, **input): 40 | new_book = BookService.create_book( 41 | name=input.get('book_name'), 42 | author_id=input.get('author_id') 43 | ) 44 | return BookEntity( 45 | name=new_book['name'], 46 | author_name=new_book['author_name'], 47 | ) 48 | 49 | 50 | # graphQL mutation 51 | class UpdateBookAndAuthorName(relay.ClientIDMutation): 52 | class Input: 53 | book_name = graphene.String(required=True) 54 | author_name = graphene.String(required=True) 55 | author_id = graphene.ID(required=True) 56 | book_id = graphene.ID(required=True) 57 | 58 | book = graphene.Field(BookEntity) 59 | 60 | @classmethod 61 | def mutate_and_get_payload(cls, root, info, **input): 62 | book = BookService.update_book_name_and_author_name( 63 | name=input.get('book_name'), 64 | id=input.get('book_id'), 65 | author_id=input.get('author_id'), 66 | author_name=input.get('author_name'), 67 | ) 68 | return BookEntity( 69 | name=book['name'], 70 | author_name=book['author_name'], 71 | ) 72 | 73 | 74 | class Mutation: 75 | create_book = CreateBook.Field() 76 | 77 | 78 | class Query: 79 | book = graphene.Field(BookEntity) 80 | 81 | def resolve_book(self, info): 82 | logger.info(f"graphQL query for book with id {info.get('id')}") 83 | book = BookService.get_book(id=info.get('id')) 84 | return BookEntity( 85 | name=book['name'], 86 | author_name=book['author_name'], 87 | ) 88 | -------------------------------------------------------------------------------- /example_domain/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BooksConfig(AppConfig): 5 | name = 'books' 6 | -------------------------------------------------------------------------------- /example_domain/interfaces.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Dict, Str # noqa 3 | 4 | # Could be an internal domain or an HTTP API client - we don't care! 5 | from src.authors.apis import AuthorAPI 6 | 7 | 8 | # plain example 9 | def update_author_name(*, author_name: Str, author_id: uuid.UUID) -> None: 10 | AuthorAPI.update_author_name( 11 | id=author_id, 12 | name=author_name, 13 | ) 14 | 15 | 16 | # class example 17 | class AuthorInterface: 18 | 19 | @staticmethod 20 | def get_author(*, id: uuid.UUID) -> Dict: 21 | return AuthorAPI.get(id=id) 22 | 23 | @staticmethod 24 | def update_author_name( 25 | *, 26 | author_name: Str, 27 | author_id: uuid.UUID, 28 | ) -> None: 29 | AuthorAPI.update_author_name( 30 | id=author_id, 31 | name=author_name, 32 | ) 33 | -------------------------------------------------------------------------------- /example_domain/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import django.contrib.postgres.fields.jsonb 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [migrations.CreateModel( 17 | name='Book', 18 | fields=[ 19 | ('name', models.CharField(max_length=64)), 20 | ('id', models.UUIDField(primary_key=True, default=uuid.uuid4, serialize=False)), 21 | ('author_id', models.UUIDField(default=uuid.uuid4, serialize=False)), 22 | ], 23 | options={ 24 | 'abstract': False, 25 | }, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /example_domain/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/django-api-domains/72575498aae5475e60b9568aee2cae257df80405/example_domain/migrations/__init__.py -------------------------------------------------------------------------------- /example_domain/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | 5 | 6 | class Book(models.Model): 7 | 8 | id = models.UUIDField(primar_key=True, default=uuid.uuid4) 9 | name = models.BooleanField(default=False) 10 | author_id = models.UUIDField(default=uuid.uuid4) 11 | -------------------------------------------------------------------------------- /example_domain/services.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import uuid 3 | from typing import Dict, Str # noqa 4 | 5 | from .interfaces import AuthorInterface 6 | from .models import Book 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | # Plain example 12 | def get_book(*, id: uuid.UUID) -> Dict: 13 | book = Book.objects.get(id=id) 14 | author = AuthorInterface.get_author(id=book.author_id) 15 | return { 16 | 'name': book.name, 17 | 'author_name': author.name, 18 | } 19 | 20 | 21 | # Class example 22 | class PGMNodeService: 23 | 24 | @staticmethod 25 | def get_book(*, id: uuid.UUID) -> Dict: 26 | book = Book.objects.get(id=id) 27 | author = AuthorInterface.get_author(id=book.author_id) 28 | return { 29 | 'name': book.name, 30 | 'author_name': author.name, 31 | } 32 | 33 | @staticmethod 34 | def create_book(*, name: Str, author_id: uuid.UUID) -> Dict: 35 | logger.info('Creating new book') 36 | new_book = Book.objects.create(name=name, author_id=author_id) 37 | author = AuthorInterface.get_author(id=new_book.author_id) 38 | return { 39 | 'name': new_book.name, 40 | 'author_name': author.name, 41 | } 42 | 43 | @staticmethod 44 | def update_book_name_and_author_name( 45 | *, 46 | name: Str, 47 | author_name: Str, 48 | author_id: uuid.UUID, 49 | id: uuid.UUID, 50 | ) -> Dict: 51 | logger.info('Updating book name and author name') 52 | book = Book.objects.get(id=id).update(name=name) 53 | author = AuthorInterface.update_author_name( 54 | name=author_name, id=author_id, 55 | ) 56 | return { 57 | 'name': book.name, 58 | 'author_name': author.name, 59 | } 60 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django API Domains 2 | theme: readthedocs 3 | 4 | pages: 5 | - Home: 'README.md' 6 | - Using this guide: 'using.md' 7 | - Domains: 'domains.md' 8 | - Styleguide: 'styleguide.md' 9 | - Files: 'files.md' 10 | - Examples: 'examples.md' 11 | - Plugins: 'plugins.md' 12 | - Testing: 'testing.md' 13 | - Appendix: 'appendix.md' 14 | - Contributing: 'contributing.md' -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.0.4 --------------------------------------------------------------------------------