This walks you through scaffolding production-grade Frappe apps with proper separation of concerns. You get a complete architecture pattern: thin DocType controllers, business logic in service modules, and cross-cutting utilities for caching, logging, and error handling. The hooks.py configuration is especially thorough, covering doc events, scheduler tasks, and the newer extend_doctype_class pattern for v16+. It includes background job setup with enqueue patterns and has practical guardrails for multi-version compatibility. The structure keeps you from putting everything in controller files, which is the rookie mistake that makes Frappe apps unmaintainable. Good starting point if you're building anything beyond a simple CRUD app.
npx -y skills add lubusin/agent-skills --skill frappe-app-development --agent claude-codeInstalls into .claude/skills of the current project.
Scaffold, structure, and architect custom Frappe applications with production-grade patterns.
hooks.py for events, scheduler, overrides# Create the app
bench new-app my_app
# Install on site
bench --site mysite.local install-app my_app
# Verify developer mode
bench --site mysite.local console
>>> frappe.conf.developer_mode # Must be True
Follow the domain architecture pattern — keep DocType controllers thin and business logic in service modules:
my_app/
├── my_app/
│ ├── __init__.py
│ ├── hooks.py # App hooks and configuration
│ ├── api.py # Public API surface (RPC endpoints)
│ ├── services/ # Business logic modules
│ │ └── billing.py
│ ├── utils/ # Cross-cutting utilities
│ │ ├── cache.py
│ │ ├── errors.py
│ │ ├── logging.py
│ │ ├── permissions.py
│ │ └── validation.py
│ ├── background_jobs/ # Async job handlers
│ │ └── export_job.py
│ ├── integrations/ # External system connectors
│ │ └── payment_gateway.py
│ ├── my_module/
│ │ ├── doctype/
│ │ │ └── my_doc/
│ │ │ ├── my_doc.json
│ │ │ ├── my_doc.py
│ │ │ ├── my_doc.js
│ │ │ └── test_my_doc.py
│ │ ├── report/
│ │ └── dashboard/
│ └── translations/
│ ├── en.csv
│ └── fr.csv
├── setup.py
└── README.md
Use the mini-app-template in assets/mini-app-template/ as a starting scaffold.
# hooks.py
app_name = "my_app"
app_title = "My App"
app_publisher = "My Company"
# DocType lifecycle events
doc_events = {
"Sales Order": {
"on_submit": "my_app.services.billing.on_order_submit",
"on_cancel": "my_app.services.billing.on_order_cancel",
},
"*": {
"after_insert": "my_app.utils.logging.log_creation",
}
}
# Scheduled tasks
scheduler_events = {
"daily": [
"my_app.background_jobs.cleanup.run_daily_cleanup"
],
"cron": {
"0 */6 * * *": [
"my_app.background_jobs.sync.sync_external_data"
]
}
}
# Client-side script injection
doctype_js = {
"Sales Order": "public/js/sales_order.js"
}
doctype_list_js = {
"Sales Order": "public/js/sales_order_list.js"
}
# Override another app's controller (use sparingly)
# override_doctype_class = {
# "ToDo": "my_app.overrides.custom_todo.CustomToDo"
# }
# Extend controller without full replacement (v16+, preferred)
# extend_doctype_class = {
# "ToDo": "my_app.overrides.todo_extension.TodoExtension"
# }
# Override whitelisted methods
# override_whitelisted_methods = {
# "frappe.client.get_list": "my_app.overrides.custom_get_list"
# }
Keep DocType controllers thin — delegate business logic to services:
# my_app/services/billing.py
import frappe
def on_order_submit(doc, method):
"""Handle order submission — called via doc_events hook."""
if doc.grand_total > 10000:
create_approval_request(doc)
generate_invoice(doc)
def generate_invoice(order):
"""Create invoice from submitted order."""
invoice = frappe.get_doc({
"doctype": "Sales Invoice",
"customer": order.customer,
"items": [
{"item_code": i.item_code, "qty": i.qty, "rate": i.rate}
for i in order.items
]
})
invoice.insert()
invoice.submit()
return invoice
# my_app/background_jobs/export_job.py
import frappe
def enqueue_export(filters):
"""Enqueue a long-running export job."""
frappe.enqueue(
"my_app.background_jobs.export_job.run_export",
filters=filters,
queue="long",
timeout=600,
is_async=True
)
def run_export(filters):
"""Execute the export — runs in background worker."""
data = frappe.get_all("Sales Order", filters=filters, fields=["*"])
# Process data...
frappe.publish_realtime("export_complete", {"count": len(data)})
# my_app/utils/cache.py
import frappe
def get_cached_settings(key):
"""Cache expensive settings lookups."""
value = frappe.cache().get_value(f"my_app:{key}")
if value is None:
value = frappe.db.get_single_value("My Settings", key)
frappe.cache().set_value(f"my_app:{key}", value)
return value
def invalidate_cache(key):
frappe.cache().delete_value(f"my_app:{key}")
# my_app/utils/errors.py
import frappe
def api_error(message, status_code=400, exc=None):
"""Consistent error response for API endpoints."""
frappe.local.response["http_status_code"] = status_code
frappe.throw(message, exc or frappe.ValidationError)
# In Python code
frappe._("Hello World") # Mark string for translation
# In JavaScript
__("Hello World") # Mark string for translation
# Translation CSV files go in my_app/translations/
# e.g., my_app/translations/fr.csv:
# Hello World,Bonjour le monde
| Feature | Minimum Version |
|---|---|
extend_doctype_class | Frappe v16+ |
REST API v2 (/api/v2/) | Frappe v15+ |
| Token-based auth | Frappe v11.0.3+ |
When targeting multiple versions, guard version-specific features:
import frappe
if hasattr(frappe, 'extend_doctype_class'):
# v16+ pattern
pass
else:
# Fallback for older versions
pass
bench --site <site> install-app my_appbench --site <site> migrate succeedsbench --site <site> run-tests --app my_appapps.txt and sites/<site>/site_config.jsonhooks.py syntax; restart benchbench doctor; verify Redisfrappe-doctype-developmentfrappe-api-developmentfrappe-desk-customizationfrappe-frontend-developmentfrappe-printing-templatesfrappe-reportsfrappe-web-formsfrappe-testingfrappe-enterprise-patternsfrappe-managerfrappe-doctype-development (hooks-extensions.md)frappe-api-development (python-api.md)frappe-frontend-development for setup.frappe-ui-patterns skill for app shell, navigation, list views, and form layouts derived from official Frappe apps.frappe.version checks for cross-version supportfixtures in hooks.py for data that should sync with app| Mistake | Why It Fails | Fix |
|---|---|---|
App not in installed_apps | App code not loaded | Run bench --site <site> install-app my_app |
| Wrong module path in hooks | Events don't fire | Verify path matches actual my_app/module/file.py structure |
| Duplicate hook registrations | Events fire multiple times | Check hooks.py for duplicates; use list not repeated keys |
| Editing hooks.py without restart | Changes not picked up | Run bench restart after hooks.py changes |
Missing __init__.py files | Module import errors | Ensure every directory has __init__.py |
| Logic in hooks.py | Hard to test, import errors | Move logic to separate modules, import in hooks |
| Building frontend with vanilla JS/jQuery | Inconsistent with ecosystem | Use Frappe UI (Vue 3); see frappe-frontend-development |
| Custom app shell for CRUD apps | Inconsistent UX | Follow CRM/Helpdesk patterns for navigation and layouts |
sickn33/antigravity-awesome-skills
moizibnyousaf/ai-agent-skills
github/awesome-copilot