Goal
Build production-ready Frappe custom apps that are modular, testable, and maintainable. Every app follows the same battle-tested architecture: separate business logic engines from Frappe ORM, expose clean APIs, schedule tasks, test pure-logic without Frappe, and use hooks/fixtures for portability.
📐 Layer 1: DocType Design
Controller Pattern (Python)
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import now
class WarehouseViolation(Document):
def validate(self):
"""Auto-fill derived fields BEFORE save."""
self.fetch_employee_details()
self.set_period()
self.fetch_penalty_from_type()
def fetch_employee_details(self):
"""Pull company, branch, name from Employee — avoid manual entry."""
if self.employee:
emp = frappe.db.get_value(
"Employee", self.employee,
["company", "branch", "employee_name"], as_dict=True,
)
if emp:
self.company = emp.company
self.branch = emp.branch
self.employee_name = emp.employee_name
def set_period(self):
"""Auto-derive period (YYYY-MM) from date field."""
if self.date:
self.period = str(self.date)[:7]
def fetch_penalty_from_type(self):
"""Auto-fill penalty points from master data if not manually set."""
if self.violation_type and not self.penalty_points:
pts = frappe.db.get_value(
"Warehouse Violation Type", self.violation_type, "penalty_points"
)
if pts:
self.penalty_points = pts
def on_submit(self):
"""Record who confirmed and when."""
self.confirmed_by = frappe.session.user
self.confirmed_at = now()
self.db_update()
frappe.msgprint(
_("Confirmed. Penalty: {0} pts for {1}.").format(
self.penalty_points, self.employee_name
),
indicator="orange", alert=True,
)
def on_cancel(self):
frappe.msgprint(
_("Cancelled for {0}.").format(self.employee_name), alert=True
)Key patterns:
validate()= auto-fill derived fields (period from date, employee details from Link)on_submit()= record audit trail (who, when), show user feedbackon_cancel()= cleanup or notify- Use
self.db_update()after modifying fields inon_submit(doc already saved) - Use
frappe.db.get_value()for single-field lookups (fast, no full doc load)
Controller Pattern (JavaScript)
frappe.ui.form.on("Warehouse Violation", {
refresh(frm) {
// Color-coded workflow state indicator
if (frm.doc.workflow_state) {
const colors = {
"Pending": "orange", "Confirmed": "green",
"Appealed": "blue", "Waived": "red"
};
frm.page.set_indicator(
frm.doc.workflow_state,
colors[frm.doc.workflow_state] || "gray"
);
}
// Dashboard indicators for key metrics
if (frm.doc.penalty_points) {
frm.dashboard.add_indicator(
__("Penalty: {0} pts", [frm.doc.penalty_points]),
frm.doc.penalty_points >= 8 ? "red" :
frm.doc.penalty_points >= 5 ? "orange" : "blue"
);
}
// Custom action buttons (non-submitted docs only)
if (!frm.is_new() && frm.doc.docstatus === 0) {
frm.add_custom_button(__("View Dashboard"), function () {
frappe.set_route("query-report", "Employee Summary", {
period: frm.doc.period
});
}, __("Actions"));
}
},
date(frm) {
// Auto-fill period from date
boxme.autoSetPeriod(frm, "date", "period");
},
violation_type(frm) {
// Auto-fill penalty from master data
if (frm.doc.violation_type) {
frappe.db.get_value(
"Warehouse Violation Type",
frm.doc.violation_type,
"penalty_points",
(r) => {
if (r && r.penalty_points) {
frm.set_value("penalty_points", r.penalty_points);
}
}
);
}
}
});DocType Design Rules
✅ DO:
- Use Link fields to reference other DocTypes (Employee, Company, Branch)
- Add "period" field (Data, YYYY-MM) auto-derived from date
- Use Submittable DocTypes for records that need approval workflows
- Set "module" in DocType JSON to your app module name
- Add naming_rule or autoname for meaningful document names
❌ DON'T:
- Hardcode company/branch/employee names — always use Link fields
- Put business logic in DocType controllers — use engines/ instead
- Forget to add "module" property — makes fixtures export fail
- Create DocType without think about workflow states first🌐 Layer 3: API Endpoints
External Webhook Pattern (WMS/ERP Integration)
@frappe.whitelist(allow_guest=False)
def receive_data(data):
"""
POST /api/method/my_app.api.external.receive_data
Body: {"data": [{...}, {...}]}
Auth: token api_key:api_secret
"""
if isinstance(data, str):
data = json.loads(data)
created = skipped = 0
errors = []
for rec in data:
emp_code = rec.get("employee_code")
try:
employee = frappe.db.get_value(
"Employee", {"employee_number": emp_code}, "name"
)
if not employee:
errors.append({"code": emp_code, "error": f"Not found"})
skipped += 1
continue
# Deduplicate: skip if already exists
if frappe.db.exists("My DocType", {"employee": employee, "date": rec["date"]}):
skipped += 1
continue
doc = frappe.get_doc({
"doctype": "My DocType",
"employee": employee,
"date": rec.get("date"),
"value": rec.get("value", 0),
"source": "external_system",
})
doc.insert(ignore_permissions=True)
created += 1
except Exception as exc:
errors.append({"code": emp_code, "error": str(exc)})
skipped += 1
frappe.log_error(str(exc), "receive_data")
frappe.db.commit()
return {"status": "ok" if not errors else "partial",
"created": created, "skipped": skipped, "errors": errors}Internal API Pattern (UI + Dashboard)
@frappe.whitelist()
def calculate_for_employee(employee, period_type, period_value, overwrite=False):
"""Manual trigger with idempotent upsert."""
if not frappe.has_permission("My DocType", "write"):
frappe.throw(_("Permission denied"), frappe.PermissionError)
data = engine.calculate(employee, period_type, period_value)
existing = frappe.db.get_value("My DocType", {
"employee": employee, "period_type": period_type,
"period_value": period_value,
})
if existing:
if not overwrite:
return {"status": "exists", "name": existing}
doc = frappe.get_doc("My DocType", existing)
if doc.docstatus == 1:
frappe.throw(_("Cannot overwrite submitted record"))
doc.update(data)
doc.save(ignore_permissions=True)
frappe.db.commit()
return {"status": "updated", "name": doc.name}
doc = frappe.get_doc({"doctype": "My DocType", **data})
doc.insert(ignore_permissions=True)
frappe.db.commit()
return {"status": "created", "name": doc.name}Permission Query Pattern
def get_permission_query(user: str) -> str:
"""Row-level security: user sees only their own records unless manager/admin."""
roles = frappe.get_roles(user)
admin_roles = {"System Manager", "HR Reviewer", "Department Manager"}
if admin_roles & set(roles):
return "" # No filter = see all
emp = frappe.db.get_value("Employee", {"user_id": user}, "name")
if emp:
return f"`tabMy DocType`.employee = '{emp}'"
return "1=0" # See nothingKey patterns:
allow_guest=Falsefor webhooks (requires API key auth)- Parse
dataas string or dict (Frappe may pass either) - Batch processing with created/skipped/errors counters
- Idempotent upsert: check existing → update or insert
frappe.db.commit()after bulk operations- Permission queries for row-level security
🔗 hooks.py — The Nervous System
app_name = "my_app"
app_title = "My App"
app_publisher = "My Company"
app_description = "App description"
app_license = "MIT"
required_apps = ["frappe/hrms"] # Declare dependencies
# ── Assets ────────────────────────────────────────────────────────────────
app_include_js = ["/assets/my_app/js/my_app.js"]
# ── DocType JS overrides (for core DocTypes like Employee) ────────────────
doctype_js = {
"Employee": "public/js/employee.js"
}
# ── After install / migrate ───────────────────────────────────────────────
after_install = "my_app.setup.install.after_install"
after_migrate = "my_app.setup.install.after_migrate"
# ── Doc Events (server-side hooks) ────────────────────────────────────────
doc_events = {
"My Submittable Doc": {
"on_submit": "my_app.engines.my_engine.on_doc_submit",
"on_cancel": "my_app.engines.my_engine.on_doc_cancel",
},
"My Auto Doc": {
"after_insert": "my_app.engines.my_engine.on_doc_insert",
},
}
# ── Fixtures (portable across sites) ─────────────────────────────────────
fixtures = [
{"dt": "Role", "filters": [["name", "in", ["My Role 1", "My Role 2"]]]},
{"dt": "Custom Field", "filters": [["module", "=", "My App"]]},
{"dt": "Workflow", "filters": [["document_type", "in", ["My Doc"]]]},
{"dt": "Workflow State", "filters": [["name", "in", [
"Draft", "Pending", "Approved", "Rejected"
]]]},
]
# ── Permission query conditions ───────────────────────────────────────────
permission_query_conditions = {
"My Score Doc": "my_app.api.internal.get_permission_query",
}📊 Layer 6: Reports
Script Report Pattern
Python (employee_scoring_summary.py):
def execute(filters=None):
filters = filters or {}
columns = get_columns()
data = get_data(filters)
chart = get_chart(data)
report_summary = get_report_summary(data)
return columns, data, None, chart, report_summary
def get_columns():
return [
{"fieldname": "employee", "label": "Employee", "fieldtype": "Link",
"options": "Employee", "width": 120},
{"fieldname": "value", "label": "Value", "fieldtype": "Float",
"precision": 1, "width": 90},
# ... more columns
]
def get_data(filters):
conditions = "WHERE d.docstatus != 2"
values = {}
if filters.get("company"):
conditions += " AND d.company = %(company)s"
values["company"] = filters["company"]
# ... more filters
return frappe.db.sql(f"""
SELECT d.employee, d.value, ...
FROM `tabMy Doc` d {conditions}
ORDER BY d.value DESC
""", values, as_dict=True)
def get_chart(data):
if not data:
return None
top = data[:10]
return {
"data": {
"labels": [r.employee_name for r in top],
"datasets": [
{"name": "Value", "values": [r.value for r in top]},
],
},
"type": "bar",
"colors": ["#2980b9"],
}
def get_report_summary(data):
if not data:
return []
total = len(data)
avg = sum(r.value for r in data) / total if total else 0
return [
{"value": total, "label": "Total", "datatype": "Int", "indicator": "Blue"},
{"value": round(avg, 1), "label": "Average", "datatype": "Float",
"indicator": "Green" if avg >= 50 else "Orange"},
]JavaScript (employee_scoring_summary.js):
frappe.query_reports["Employee Scoring Summary"] = {
filters: [
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
},
{
fieldname: "period",
label: __("Period (YYYY-MM)"),
fieldtype: "Data",
default: frappe.datetime.get_today().substring(0, 7),
},
],
};🧪 Layer 6: Testing
Standalone Pure-Logic Tests (No Frappe!)
"""
Run: python -m pytest my_app/tests/test_engine.py -v
No Frappe instance needed — tests only pure-logic functions.
"""
import unittest
# Inline pure-logic (copy from engine, no frappe imports)
PPH_LEVEL_ORDER = ["needs_improvement", "average", "good", "excellent"]
_DEFAULT_THRESHOLDS = [
{"level": "needs_improvement", "min_pph": 0, "max_pph": 35},
{"level": "average", "min_pph": 35, "max_pph": 55},
]
# ... paste pure functions here
class TestClassifyLevel(unittest.TestCase):
def test_boundary(self):
self.assertEqual(classify_level(35, _DEFAULT_THRESHOLDS), "average")
def test_empty_defaults(self):
self.assertEqual(classify_level(60, []), "good")
class TestLevelOrdering(unittest.TestCase):
def test_gte(self):
self.assertTrue(level_gte("excellent", "good"))
self.assertFalse(level_gte("average", "good"))
if __name__ == "__main__":
unittest.main(verbosity=2)Why inline pure functions in test files?
- Tests run with
pytest -v— no bench, no MariaDB, no Frappe site - CI/CD friendly — fast, isolated, reliable
- Keep the test file self-contained
Examples
Example 1: Scaffold a New Frappe Custom App
WARNING: Always use bench new-app to create the initial app structure! Do not create it manually, as Frappe Cloud and other tools rely on standard boilerplate files (setup.py, MANIFEST.in, patches.txt, hooks.py, etc.) that are generated by bench. Failure to do so will result in "Not a valid Frappe App" errors during deployment.
# 1. Create app (this generates setup.py, pyproject.toml, MANIFEST.in, requirements.txt, etc.)
bench new-app my_custom_app
# 2. Create module structure inside the generated app
mkdir -p apps/my_custom_app/my_custom_app/{engines,api,tasks,setup,tests,scripts,fixtures}
mkdir -p apps/my_custom_app/my_custom_app/public/js
touch apps/my_custom_app/my_custom_app/{engines,api,tasks,setup,tests}/__init__.py
# 3. Install on site
bench --site mysite.localhost install-app my_custom_app
# 4. Enable dev mode
bench --site mysite.localhost set-config developer_mode 1Example 2: Add a New DocType with Full Stack
- Create DocType via Frappe UI or JSON
- Write controller (
.py) with validate/on_submit/on_cancel - Write client script (
.js) with refresh/field triggers - Register doc_events in
hooks.py - Add engine function for business logic
- Write API endpoint calling engine
- Add scheduler task for batch processing
- Write tests for pure logic
bench --site mysite migrate && bench build --app my_custom_app
🚀 Layer 9: CI/CD & GitHub Actions
When building or fixing CI/CD pipelines (ci.yml, linter.yml) for Frappe apps, adhere to the following stability rules:
Versions & Environments
- Python: Strictly unify the Python version across ALL jobs (e.g.,
python-version: '3.12'). Avoid alpha versions (e.g., 3.14) that break dependencies. - Node.js: Use Node 20+ (e.g.,
node-version: 20). Frappe v15 ecosystem deeply relies on packages likejsdom@29+which explicitly drop Node 18 support. - GitHub Actions: Use stable tags (
@v4,@v5). Never guess action versions (@v6) as it causes silent failures.
Frappe Installation in CI
- Skip Assets: Always use
--skip-assetswithbench get-appto prevent memory exhaustion (OOM errors) and esbuild race conditions during concurrent CI setups. - Local App Registration: If you
bench get-app /path/to/local/app --skip-assets, the app is NOT added tosites/apps.txtautomatically. - apps.txt Fix: Manually append it safely before
bench install-app:bash(Always usegrep -q my_app sites/apps.txt 2>/dev/null || printf '\nmy_app\n' >> sites/apps.txtprintf '\n...'instead ofechoto prevent concatenating with the last line if it lacks a trailing newline.)