Odoo and ERPNext solve the ERP problem for most companies. If your processes fit within 20% of a standard module’s configuration options, customizing an existing platform is almost always cheaper than building one — we’ve written about implementing ERPNext and about why ERP projects fail for exactly that reason.
But there’s a real minority of cases where a custom build wins: your core process is your competitive advantage and doesn’t map to any standard module (a commodity trading desk with FIFO lot allocation, a depot operation with weighbridge integration, a manufacturer with a proprietary yield-costing model), or you need to embed the ERP inside a product you’re selling rather than run it as internal tooling. This guide is for that case: how to actually structure an ERP-style system in Django, not "should you."
Everything below is architecture and patterns, not a full working system — the goal is to save you from the mistakes that only show up after your third or fourth business document type.
1. App structure: organize by business domain, not by technical layer
The most common early mistake is a single core app with every model in it. Split by domain from day one — it costs nothing early and saves a rewrite later:
flowchart TD
A["parties<br/>Customer, Supplier, Contact"] --> E["sales<br/>Quotation, SalesOrder, Invoice"]
A --> F["purchasing<br/>PurchaseOrder, Receipt, Bill"]
B["inventory<br/>Item, Warehouse, StockLedgerEntry"] --> E
B --> F
C["accounting<br/>Account, GLEntry, Period"] --> E
C --> F
D["core<br/>SubmittableDocument, NamingSeries, AuditLog"] --> E
D --> F
D --> B
D --> C
core holds only shared abstractions — no business logic. parties unifies customers and suppliers into one underlying model (most real-world entities are both, at different times), which avoids the classic problem of duplicated address/contact logic across Sales and Purchasing.
2. The submittable-document pattern
This is the single most valuable pattern to steal from mature ERPs like ERPNext, and Django doesn’t give it to you for free — you have to build it. The core idea: transactional documents (Sales Order, Invoice, Payment) move through draft → submitted → cancelled, and only submitted documents are allowed to trigger side effects (stock movement, GL postings). Draft documents are freely editable; submitted documents are effectively immutable.
# core/models.py
from django.db import models
from django.core.exceptions import ValidationError
class DocStatus(models.IntegerChoices):
DRAFT = 0, "Draft"
SUBMITTED = 1, "Submitted"
CANCELLED = 2, "Cancelled"
class SubmittableDocument(models.Model):
docstatus = models.IntegerField(choices=DocStatus.choices, default=DocStatus.DRAFT)
submitted_at = models.DateTimeField(null=True, blank=True)
submitted_by = models.ForeignKey(
"auth.User", null=True, blank=True, on_delete=models.PROTECT,
related_name="+",
)
class Meta:
abstract = True
def save(self, *args, **kwargs):
if self.pk:
original = type(self).objects.get(pk=self.pk)
if original.docstatus == DocStatus.SUBMITTED and self.docstatus == DocStatus.SUBMITTED:
raise ValidationError("Submitted documents cannot be edited. Cancel and amend instead.")
super().save(*args, **kwargs)
def submit(self, user):
if self.docstatus != DocStatus.DRAFT:
raise ValidationError("Only draft documents can be submitted.")
self.on_submit()
self.docstatus = DocStatus.SUBMITTED
self.submitted_by = user
self.save()
def on_submit(self):
"""Override in subclasses to post GL entries, move stock, etc."""
raise NotImplementedError
Every transactional model (SalesOrder, PurchaseInvoice, Payment) inherits SubmittableDocument and implements on_submit(). This single pattern is what gives you an audit trail instead of a spreadsheet with delete permissions — and it’s the piece teams most often skip when they build ERP-adjacent systems in Django, right up until an auditor asks why a posted invoice’s amount changed.
3. Double-entry accounting as a first-class model, not an afterthought
If your custom system touches money at all, model the general ledger as an immutable, append-only table from the start. Retrofitting double-entry accounting onto a system that was tracking balances as mutable integer fields is a rewrite, not a migration.
erDiagram
ACCOUNT ||--o{ GL_ENTRY : posts_to
PARTY ||--o{ SALES_INVOICE : billed_to
SALES_INVOICE ||--|{ SALES_INVOICE_ITEM : contains
SALES_INVOICE ||--o{ GL_ENTRY : generates
PAYMENT ||--o{ GL_ENTRY : generates
PAYMENT ||--o{ SALES_INVOICE : settles
# accounting/models.py
class GLEntry(models.Model):
account = models.ForeignKey("Account", on_delete=models.PROTECT)
debit = models.DecimalField(max_digits=18, decimal_places=2, default=0)
credit = models.DecimalField(max_digits=18, decimal_places=2, default=0)
posting_date = models.DateField()
voucher_type = models.CharField(max_length=50) # "Sales Invoice", "Payment", ...
voucher_id = models.PositiveIntegerField()
party = models.ForeignKey("parties.Party", null=True, on_delete=models.PROTECT)
class Meta:
indexes = [
models.Index(fields=["account", "posting_date"]),
models.Index(fields=["voucher_type", "voucher_id"]),
]
Every GLEntry row is created once, inside on_submit(), and never updated — corrections are reversing entries plus a new correct entry, exactly as a paper ledger would require. This makes reconciliation and audit trivially traceable and makes "why doesn’t my balance sheet balance" a solvable query instead of a forensic investigation.
4. Numbering, sequencing, and the concurrency trap
Every document needs a human-readable ID (SO-2026-00042). The naive approach — read the last number, increment, save — is a race condition under concurrent load: two Sales Orders submitted in the same second can grab the same number. Use a dedicated sequence row with select_for_update():
def get_next_number(series_key: str) -> str:
with transaction.atomic():
series, _ = NamingSeries.objects.select_for_update().get_or_create(
key=series_key, defaults={"current": 0}
)
series.current += 1
series.save()
return f"{series_key}-{series.current:05d}"
This is a small piece of code that causes an outsized amount of production pain when skipped — duplicate invoice numbers are the kind of bug that surfaces during an audit, not during testing.
5. Workflow and approval states
Naming series and docstatus cover the submit/cancel lifecycle; approval chains (Draft → Pending Manager Approval → Approved) are a separate concern and don’t belong hardcoded into your models. A finite-state-machine library (django-fsm or a hand-rolled equivalent) keeps transition rules and permission checks in one place instead of scattered across views:
from django_fsm import FSMField, transition
class PurchaseRequisition(SubmittableDocument):
state = FSMField(default="draft")
@transition(field=state, source="draft", target="pending_approval")
def request_approval(self):
pass
@transition(field=state, source="pending_approval", target="approved",
permission=lambda instance, user: user.has_perm("purchasing.approve_requisition"))
def approve(self):
pass
Keep the FSM for human approval workflows and docstatus for the system-level draft/submit/cancel lifecycle — conflating the two is a common source of confusing, hard-to-test state.
6. Posting side effects asynchronously
on_submit() for a Sales Invoice might need to post GL entries, update stock, recompute customer outstanding balances, and trigger a notification. Doing all of that synchronously inside the HTTP request is what makes "Submit" buttons feel slow under load — the same problem covered in our ERP performance guide for Odoo and ERPNext applies just as much to something you build yourself.
Split it: the parts that must be atomic with the submit transaction (GL entries, stock ledger — because a user should never see "Submitted" on a document that didn’t actually post) stay synchronous inside the database transaction; the parts that are best-effort (email notification, PDF generation, analytics recompute) go to Celery:
def on_submit(self):
with transaction.atomic():
self._post_gl_entries()
self._update_stock_ledger()
send_invoice_notification.delay(self.id) # Celery task, fire-and-forget
7. Permissions: role-based first, row-level only where it earns its cost
Django’s built-in Group/Permission system handles "can this user submit Sales Orders" perfectly well. Row-level permission ("can this user see only their own territory’s Sales Orders") is a much heavier feature — django-guardian or a custom queryset filter on your base manager both work, but every row-level rule you add is a query-plan cost that compounds at scale, per the same N+1 and per-row-evaluation traps that show up in Odoo/ERPNext customizations. Default to role-based; add row-level scoping only for the specific models where it’s a real business requirement, not everywhere "to be safe."
8. API layer with Django REST Framework
If the ERP needs a mobile app, a portal, or third-party integrations (and most eventually do), expose the document lifecycle through DRF rather than only server-rendered views — it forces a cleaner separation between the state machine and the presentation layer:
class SalesOrderViewSet(viewsets.ModelViewSet):
queryset = SalesOrder.objects.all()
serializer_class = SalesOrderSerializer
@action(detail=True, methods=["post"])
def submit(self, request, pk=None):
order = self.get_object()
order.submit(user=request.user)
return Response(SalesOrderSerializer(order).data)
Nested line items (SalesOrderItem) serialize well with DRF’s writable nested serializers, but validate totals server-side regardless of what the client sends — never trust a client-computed line total or tax amount.
9. What you’re signing up for by not buying
Building this yourself means you now own: migrations forever, security patching, report-building tooling that ERPNext/Odoo ship out of the box, and every edge case in tax and rounding logic that a mature platform has already hit and fixed. The honest reason to build is that your core workflow is genuinely novel — not that "Odoo felt too heavy" or "we wanted full control." If the real motivation is customization pain on an existing platform, revisit why ERP projects fail before committing to a from-scratch build; a surprising number of "we need custom" conversations turn out to be configuration problems.
Checklist before you write your first model
- Have you separated apps by business domain, not technical layer?
- Does every transactional model have a draft/submit/cancel lifecycle, and are submitted documents actually immutable?
- Is the ledger (if you have one) append-only, with corrections as reversing entries?
- Is document numbering safe under concurrent submits (
select_for_update, not read-increment-save)? - Are approval workflows separated from the submit/cancel lifecycle?
- Are slow, non-critical side effects pushed to Celery instead of blocking the submit request?
- Is permission logic role-based by default, with row-level rules added only where justified?
- Have you re-confirmed that no existing platform (Odoo, ERPNext) already does 80% of this?
Sources:
- Django Documentation — Model Meta options
- Django Documentation — select_for_update
- django-fsm — GitHub
- Django REST Framework — Writable nested serializers
Latest Posts
- Passkey Enrollment Is Your New Attack Surface: What Pass-the-Passkey Means for Your IdP August 22, 2026
- Your Attacker Might Already Be an AI Agent: What the OpenAI–Hugging Face Incident Means for Your SOC August 17, 2026
- Implementing OCPI 2.2.1: A Developer’s Guide to Locations, Sessions, and CDRs August 7, 2026
- OCPI Explained: What CPOs and eMSPs Actually Need to Build for EV Roaming August 7, 2026
- The Landlocked Sea Bass: Building an Automated Feeding System for Marine Fish Far From the Ocean July 31, 2026
- Your Shop Floor Speaks Five Dialects: Why OPC UA Doesn’t Solve Protocol Fragmentation July 30, 2026