$npx -y skills add lubusIN/frappe-skills --skill frappe-app-developmentScaffold and architect custom Frappe apps including app structure, hooks, background jobs, service layers, and production hardening. Use when creating new apps, setting up app architecture, or implementing cross-cutting patterns like caching, logging, and error handling.
| 1 | # Frappe App Development |
| 2 | |
| 3 | Scaffold, structure, and architect custom Frappe applications with production-grade patterns. |
| 4 | |
| 5 | ## When to use |
| 6 | |
| 7 | - Creating a new custom Frappe app from scratch |
| 8 | - Setting up app architecture (modules, services, utils) |
| 9 | - Configuring `hooks.py` for events, scheduler, overrides |
| 10 | - Implementing background jobs and async processing |
| 11 | - Building service layers and domain logic patterns |
| 12 | - Adding caching, logging, error handling utilities |
| 13 | - Preparing apps for production deployment |
| 14 | - Managing translations, versioning, and packaging |
| 15 | |
| 16 | ## Inputs required |
| 17 | |
| 18 | - App name and purpose |
| 19 | - Target Frappe version (v13+, v15+, v16+) |
| 20 | - Module structure (which DocTypes, APIs, services) |
| 21 | - Whether hooks/overrides of other apps are needed |
| 22 | - Background job requirements |
| 23 | - Production readiness needs |
| 24 | |
| 25 | ## Procedure |
| 26 | |
| 27 | ### 0) Scaffold the app |
| 28 | |
| 29 | ```bash |
| 30 | # Create the app |
| 31 | bench new-app my_app |
| 32 | |
| 33 | # Install on site |
| 34 | bench --site mysite.local install-app my_app |
| 35 | |
| 36 | # Verify developer mode |
| 37 | bench --site mysite.local console |
| 38 | >>> frappe.conf.developer_mode # Must be True |
| 39 | ``` |
| 40 | |
| 41 | ### 1) Plan app structure |
| 42 | |
| 43 | Follow the domain architecture pattern — keep DocType controllers thin and business logic in service modules: |
| 44 | |
| 45 | ``` |
| 46 | my_app/ |
| 47 | ├── my_app/ |
| 48 | │ ├── __init__.py |
| 49 | │ ├── hooks.py # App hooks and configuration |
| 50 | │ ├── api.py # Public API surface (RPC endpoints) |
| 51 | │ ├── services/ # Business logic modules |
| 52 | │ │ └── billing.py |
| 53 | │ ├── utils/ # Cross-cutting utilities |
| 54 | │ │ ├── cache.py |
| 55 | │ │ ├── errors.py |
| 56 | │ │ ├── logging.py |
| 57 | │ │ ├── permissions.py |
| 58 | │ │ └── validation.py |
| 59 | │ ├── background_jobs/ # Async job handlers |
| 60 | │ │ └── export_job.py |
| 61 | │ ├── integrations/ # External system connectors |
| 62 | │ │ └── payment_gateway.py |
| 63 | │ ├── my_module/ |
| 64 | │ │ ├── doctype/ |
| 65 | │ │ │ └── my_doc/ |
| 66 | │ │ │ ├── my_doc.json |
| 67 | │ │ │ ├── my_doc.py |
| 68 | │ │ │ ├── my_doc.js |
| 69 | │ │ │ └── test_my_doc.py |
| 70 | │ │ ├── report/ |
| 71 | │ │ └── dashboard/ |
| 72 | │ └── translations/ |
| 73 | │ ├── en.csv |
| 74 | │ └── fr.csv |
| 75 | ├── setup.py |
| 76 | └── README.md |
| 77 | ``` |
| 78 | |
| 79 | Use the mini-app-template in `assets/mini-app-template/` as a starting scaffold. |
| 80 | |
| 81 | ### 2) Configure hooks.py |
| 82 | |
| 83 | ```python |
| 84 | # hooks.py |
| 85 | |
| 86 | app_name = "my_app" |
| 87 | app_title = "My App" |
| 88 | app_publisher = "My Company" |
| 89 | |
| 90 | # DocType lifecycle events |
| 91 | doc_events = { |
| 92 | "Sales Order": { |
| 93 | "on_submit": "my_app.services.billing.on_order_submit", |
| 94 | "on_cancel": "my_app.services.billing.on_order_cancel", |
| 95 | }, |
| 96 | "*": { |
| 97 | "after_insert": "my_app.utils.logging.log_creation", |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | # Scheduled tasks |
| 102 | scheduler_events = { |
| 103 | "daily": [ |
| 104 | "my_app.background_jobs.cleanup.run_daily_cleanup" |
| 105 | ], |
| 106 | "cron": { |
| 107 | "0 */6 * * *": [ |
| 108 | "my_app.background_jobs.sync.sync_external_data" |
| 109 | ] |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | # Client-side script injection |
| 114 | doctype_js = { |
| 115 | "Sales Order": "public/js/sales_order.js" |
| 116 | } |
| 117 | |
| 118 | doctype_list_js = { |
| 119 | "Sales Order": "public/js/sales_order_list.js" |
| 120 | } |
| 121 | |
| 122 | # Override another app's controller (use sparingly) |
| 123 | # override_doctype_class = { |
| 124 | # "ToDo": "my_app.overrides.custom_todo.CustomToDo" |
| 125 | # } |
| 126 | |
| 127 | # Extend controller without full replacement (v16+, preferred) |
| 128 | # extend_doctype_class = { |
| 129 | # "ToDo": "my_app.overrides.todo_extension.TodoExtension" |
| 130 | # } |
| 131 | |
| 132 | # Override whitelisted methods |
| 133 | # override_whitelisted_methods = { |
| 134 | # "frappe.client.get_list": "my_app.overrides.custom_get_list" |
| 135 | # } |
| 136 | ``` |
| 137 | |
| 138 | ### 3) Implement service layer |
| 139 | |
| 140 | Keep DocType controllers thin — delegate business logic to services: |
| 141 | |
| 142 | ```python |
| 143 | # my_app/services/billing.py |
| 144 | import frappe |
| 145 | |
| 146 | def on_order_submit(doc, method): |
| 147 | """Handle order submission — called via doc_events hook.""" |
| 148 | if doc.grand_total > 10000: |
| 149 | create_approval_request(doc) |
| 150 | generate_invoice(doc) |
| 151 | |
| 152 | def generate_invoice(order): |
| 153 | """Create invoice from submitted order.""" |
| 154 | invoice = frappe.get_doc({ |
| 155 | "doctype": "Sales Invoice", |
| 156 | "customer": order.customer, |
| 157 | "items": [ |
| 158 | {"item_code": i.item_code, "qty": i.qty, "rate": i.rate} |
| 159 | for i in order.items |
| 160 | ] |
| 161 | }) |
| 162 | invoice.insert() |
| 163 | invoice.submit() |
| 164 | return invoice |
| 165 | ``` |
| 166 | |
| 167 | ### 4) Set up background jobs |
| 168 | |
| 169 | ```python |
| 170 | # my_app/background_jobs/export_job.py |
| 171 | import frappe |
| 172 | |
| 173 | def enqueue_export(filters): |
| 174 | """Enqueue a long-running export job.""" |
| 175 | frappe.enqueue( |
| 176 | "my_app.background_jobs.export_job.run_export", |
| 177 | filters=filters, |
| 178 | queue="long", |
| 179 | timeout=600, |
| 180 | is_async=True |
| 181 | ) |
| 182 | |
| 183 | def run_export(filters): |
| 184 | """Execute the export — runs in background worker.""" |
| 185 | data = frappe.get_all("Sales Order", filters=filters, fields=["*"]) |