π§± Why a Custom App, Not Core Edits
Part 1's best practices already state the rule once: never modify ERPNext (or Frappe) core source. This article is about actually living by that rule while still building something real. The reason is mechanical, not philosophical β apps/erpnext/ and apps/frappe/ are git checkouts that bench update pulls fresh code into. Any line you hand-edit there either gets silently overwritten on the next update, or causes a merge conflict that halts the update entirely. Either outcome destroys the one thing an ERP system depends on most: a safe, repeatable upgrade path. This is the same discipline as the "don't silently edit history" principle behind immutable audit trails in ERP accounting β a core file you quietly patched is functionally the same problem as a ledger entry quietly changed after the fact: nobody downstream can trust what they're looking at anymore.
The alternative is to treat ERPNext core as a fixed, versioned dependency and build everything domain-specific as a separate, installable app that talks to core only through its public surface β Link fields, hooks, and the REST API. That target architecture is a straight line:
This is deliberately the same end state as Part 1's customization decision tree: "need new DocTypes, deep integration, or reusable logic β build a Custom App." Everything below is what actually happens once you reach that end node.
ποΈ Scaffolding the App
Two bench commands take you from nothing to an installed, runnable custom app:
bench new-app my_custom_app
bench --site site1.local install-app my_custom_app
bench new-app generates the app's skeleton directory (see the next section) and registers its name in the bench's apps.txt, so the bench knows this app exists alongside frappe and erpnext. It does not touch any site's database. bench --site SITE install-app is the separate step that actually activates the app for one specific site: it runs the app's install hooks and applies any pending migrations (new DocType tables, patches) against that site's database. This mirrors the get-app vs install-app split covered in Part 1's installation section β fetching/creating an app's code is one action, enabling it for a given site is another, and one bench can have a custom app installed on some sites and not others.
π App Directory Structure
my_custom_app/
βββ hooks.py
βββ modules.txt
βββ patches.txt
βββ public/
βββ templates/
βββ my_custom_app/
βββ my_module/
βββ doctype/
βββ report/
βββ page/
| Entry | What it's for |
|---|---|
hooks.py | App configuration β doc_events, scheduler_events, includes, fixtures, and more. Arguably the single most important file in the whole app; it's the declarative wiring between your code and Frappe's lifecycle. |
modules.txt | Declares which Module Def(s) this app owns β the grouping that makes the custom DocTypes show up under a named module in the Desk. |
patches.txt | Lists data-migration patch scripts, run in order, whenever bench migrate runs against a site with this app installed. |
public/ | JS, CSS, and image assets served to the browser. |
templates/ | Jinja templates for web pages, emails, and print formats. |
doctype/ | One folder per custom DocType, each holding its .json definition, .py controller, and .js client script. |
report/ | Custom Query Reports and Script Reports. |
page/ | Custom Desk pages and dashboards that aren't a standard DocType list/form. |
πͺ Hooks & Events, In Depth
Part 1 listed the hook categories briefly. Here's what each one is actually for:
| Hook | Purpose | Example use |
|---|---|---|
doc_events | React to a DocType's lifecycle events | on_submit of Sales Invoice triggers custom logic |
scheduler_events | Cron-like background jobs | A nightly job emails overdue-invoice reminders |
app_include_js / app_include_css | Load custom JS/CSS globally across the Desk | A shared UI helper available on every screen |
fixtures | Export specific records as JSON so they reinstall automatically on a fresh site | Custom Field, Role, and Workflow definitions shipped with the app |
permission_query_conditions | Inject an extra SQL WHERE-style condition into list queries | Row-level security beyond standard Role/User Permissions |
has_permission | A Python function controlling document-level access programmatically | Custom rule: only the assigned collector may edit a Lab Sample |
override_doctype_class | Replace a standard DocType's controller class with your own subclass | Add/override methods on Sales Invoice without touching its source file |
The document-save lifecycle those doc_events hooks tap into is covered in full, including where accounting/stock side-effects fire, in Part 5's document lifecycle internals β rather than re-deriving that diagram here, the point to take from it is this: doc_events hooks are how your custom app taps into validate / before_save / on_update / after_save / on_submit / on_cancel for ANY DocType, including ERPNext's own, without editing that DocType's source file. This is the mechanism that makes "integrate, don't edit core" actually practical rather than just a rule on paper.
𧬠Creating a Custom DocType
To make the rest of this article concrete, walk through one real example: Lab Sample β the same DocType briefly foreshadowed in Part 1's Lab Management case study, and the one Part 9's capstone builds out fully.
| Field | Type | Notes |
|---|---|---|
sample_id | Data | Set via a naming series, e.g. LAB-SAMPLE-.YYYY.-.##### |
patient | Link β Patient | Patient is itself a custom DocType this same app defines |
sample_type | Select | Options like Blood / Urine / Tissue |
collection_date | Date | |
status | Select | Draft / Collected / In Lab / Reported |
collector | Link β Employee | Reuses ERPNext's own Employee master directly |
tests | Table β Lab Sample Test | Child DocType holding the individual tests ordered on this sample |
"Lab Sample Test" is a child DocType: its "Is Child Table" flag is checked, and every row it stores carries the same four framework-managed fields β parent, parenttype, parentfield, idx β covered in full in Part 4's child tables deep-dive. Nothing about a custom app's child tables works differently from ERPNext's own; that's exactly the point β a custom DocType is a first-class citizen of the same framework, not a bolted-on approximation of one.
π Linking a Custom DocType Into ERPNext Core
The technique that actually makes a custom app "integrate" rather than just "coexist" is ordinary Link fields pointed at core DocTypes:
| Field on custom DocType | Link target | Why |
|---|---|---|
Lab Sample.patient | Patient (custom) β patient.customer β Customer | So a lab bill can be raised through the standard Sales Invoice flow, against a real Customer record |
Lab Sample.company | Company | Every billable/accounting-adjacent document needs a Company, same as core transactions |
Lab Sample Test.item | Item | Each lab test maps to a billable Item, so pricing and invoicing reuse ERPNext's existing Item/Price List machinery |
Lab Sample.collector | Employee | Reuses the existing HR master instead of a custom "staff" list |
Customer
Item
Company
Employee
Patient
Sample
Test
ERPNext Core (top) Β· Custom App (bottom)
Every one of these is a plain foreign-key-style Link field, exactly as described in Part 1's DocType β Table architecture. Nothing about linking into core requires touching core's schema β the custom DocType's own JSON definition simply names the target DocType, and the framework generates the column and the UI picker for you.
πͺ Three Levels of Integration
Part 1's custom-module section introduced three integration levels in brief. Each one has a concrete action behind it:
| Level | Concrete action |
|---|---|
| Level 1 β UI | Add a Workspace/module icon and dashboard shortcuts so Lab Sample, Patient, and the rest appear in the sidebar like a native module, not a hidden feature only power users can find |
| Level 2 β Data | The Link-field wiring from the previous section, plus a Dynamic Link wherever a custom DocType needs a polymorphic reference (e.g. an Address that could belong to either a Patient or a Supplier) |
| Level 3 β Business Logic | A doc_events hook β e.g. on Lab Report's on_submit β that programmatically creates a Sales Invoice against the linked Customer and Item. This is the actual payoff of doing Level 2 wiring correctly: the business logic has real records to act on |
A custom app that only does Level 2 works technically but feels bolted-on; skipping Level 1 is common enough that it gets its own line in Common Custom App Mistakes below.
π Versioning & Safe Deployment
A custom app is a git repository like any other, and it should be treated as one from the first commit. Moving customizations between dev, staging, and production should always go through fixtures and patches, never through manually editing the production database directly β the same discipline Part 1's best practices already lays out for the whole ERPNext stack. Every bench update or bench migrate should be run and verified in staging before it ever touches production.
Three commands cover the full lifecycle of applying a custom app to a site β the same ones from Part 1's cheat sheet, used together:
bench new-app my_custom_app # scaffold, once
bench --site SITE install-app my_custom_app # activate on a site
bench --site SITE migrate # apply pending patches (every deploy)
Full depth on exposing this app through the API, packaging it for deployment, and the troubleshooting playbook for when a migration or update goes wrong is Part 8's subject β this section is deliberately just the checklist, not the mechanics.
π§ Common Custom App Mistakes
| Mistake | Why it hurts |
|---|---|
| Naming a custom DocType the same as (or too close to) a core ERPNext DocType | Causes confusion for every developer after you, and risks a collision on a future ERPNext upgrade that introduces a similarly named core DocType |
Putting business logic in Client Script when it belongs in a doc_events hook | Client Script only runs in the browser β it's bypassable via the API, as covered in Part 6's customization guide; anything that must always happen belongs server-side |
| Forgetting to add fixtures for Custom Fields, Roles, or Workflows created via the UI | Those changes live only in that site's database β they don't ship with the app, so a fresh install is silently missing them |
| Tightly coupling a custom DocType's controller to ERPNext internals that aren't part of a stable public API | Breaks quietly on the next ERPNext upgrade, for the same underlying reason core source edits break β you're depending on something not guaranteed to stay the same |
| Skipping Level 1 UI integration entirely | Leaves a technically-working module that users can't discover β see Three Levels of Integration above |
πΊοΈ What's Next
This article is Part 7 of the 9-part ERPNext course, going far deeper than the brief #custom-app and #custom-module sections in Part 1 β everything from directory structure to hook categories to the full Lab Sample integration example lives here instead. It's also the destination Part 1's customization ladder points to once you outgrow the no-code tools covered in Part 6.
From here: Part 8 covers exposing this app through the REST API and deploying it safely β the full version of the checklist in Versioning & Safe Deployment above. Part 9 builds the full Laboratory Management app using exactly this structure β the same Lab Sample DocType, the same Level 1/2/3 integration, taken from example to complete capstone project.