Frappe DocType Creation
Create a production-ready Frappe v15 DocType with complete controller implementation, service layer integration, repository pattern, and test coverage.
When to Use
-
Creating a new DocType for a Frappe application
-
Need proper controller with lifecycle hooks
-
Want service layer for business logic separation
-
Require repository for clean data access
-
Building submittable/amendable documents
Arguments
/frappe-doctype <doctype_name> [--module <module>] [--submittable] [--child]
Examples:
/frappe-doctype Sales Order /frappe-doctype Invoice Item --child /frappe-doctype Purchase Request --submittable
Procedure
Step 1: Gather DocType Requirements
Ask the user for:
-
DocType Name (Title Case, e.g., "Sales Order")
-
Module (which module this belongs to)
-
DocType Type:
-
Standard (regular CRUD document)
-
Submittable (has workflow: Draft → Submitted → Cancelled)
-
Child Table (embedded in parent documents)
-
Single (configuration/settings document)
-
Key Fields (at least the primary fields needed)
-
Naming Pattern:
-
Autoname (series like SO-.YYYY.-.##### )
-
Field-based (use a specific field value)
-
Prompt (user enters name)
Step 2: Analyze and Design
Based on requirements, determine:
-
Field types and properties
-
Link relationships to other DocTypes
-
Required indexes for performance
-
Permission model (roles that can access)
-
Workflow requirements
Step 3: Generate DocType JSON
Create the DocType definition <doctype_folder>/<doctype_name>.json :
{ "name": "<DocType Name>", "module": "<Module>", "doctype": "DocType", "naming_rule": "By "Naming Series" field", "autoname": "naming_series:", "is_submittable": 0, "is_tree": 0, "istable": 0, "editable_grid": 1, "track_changes": 1, "track_seen": 1, "engine": "InnoDB", "fields": [ { "fieldname": "naming_series", "fieldtype": "Select", "label": "Series", "options": "<PREFIX>-.YYYY.-.#####", "reqd": 1, "in_list_view": 0 }, { "fieldname": "title", "fieldtype": "Data", "label": "Title", "reqd": 1, "in_list_view": 1, "in_standard_filter": 1 }, { "fieldname": "status", "fieldtype": "Select", "label": "Status", "options": "\nDraft\nPending\nCompleted\nCancelled", "default": "Draft", "in_list_view": 1, "in_standard_filter": 1 }, { "fieldname": "column_break_1", "fieldtype": "Column Break" }, { "fieldname": "date", "fieldtype": "Date", "label": "Date", "default": "Today", "reqd": 1, "in_list_view": 1 }, { "fieldname": "section_break_details", "fieldtype": "Section Break", "label": "Details" }, { "fieldname": "description", "fieldtype": "Text Editor", "label": "Description" }, { "fieldname": "amended_from", "fieldtype": "Link", "label": "Amended From", "no_copy": 1, "options": "<DocType Name>", "print_hide": 1, "read_only": 1 } ], "permissions": [ { "role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1, "submit": 0, "cancel": 0, "amend": 0 } ], "sort_field": "modified", "sort_order": "DESC", "title_field": "title" }
Step 4: Generate Controller with v15 Type Annotations
Create <doctype_folder>/<doctype_name>.py :
Copyright (c) <year>, <author> and contributors
For license information, please see license.txt
import frappe from frappe import _ from frappe.model.document import Document from frappe.model.docstatus import DocStatus # v15: Helper for docstatus checks from typing import TYPE_CHECKING
if TYPE_CHECKING: from frappe.types import DF # Import child table types if needed # from <app>.<module>.doctype.<child_doctype>.<child_doctype> import <ChildDocType>
class <DocTypeName>(Document): """ <DocType Name> - <brief description>
Lifecycle:
Draft → (validate) → Saved → (submit) → Submitted → (cancel) → Cancelled
"""
# begin: auto-generated types
# This section is auto-generated by Frappe. Do not modify manually.
if TYPE_CHECKING:
amended_from: DF.Link | None
date: DF.Date
description: DF.TextEditor | None
naming_series: DF.Literal["<PREFIX>-.YYYY.-.#####"]
status: DF.Literal["", "Draft", "Pending", "Completed", "Cancelled"]
title: DF.Data
# end: auto-generated types
def before_validate(self) -> None:
"""Auto-set default values before validation."""
self._set_defaults()
def validate(self) -> None:
"""Validate document before save. Throw exception to prevent saving."""
self._validate_business_rules()
def before_save(self) -> None:
"""Called before document is saved to database."""
self._update_status()
def after_insert(self) -> None:
"""Called after new document is inserted."""
self._notify_creation()
def on_update(self) -> None:
"""Called when existing document is updated."""
pass
def before_submit(self) -> None:
"""Called before document submission. Validate submission requirements."""
self._validate_submit_conditions()
def on_submit(self) -> None:
"""Called after document submission. Create dependent records."""
self._process_submission()
def before_cancel(self) -> None:
"""Validate cancellation conditions."""
self._validate_cancel_conditions()
def on_cancel(self) -> None:
"""Handle cancellation cleanup."""
self._process_cancellation()
def on_trash(self) -> None:
"""Called when document is deleted. Cleanup related data."""
pass
# ──────────────────────────────────────────────────────────────────────────
# Private Methods
# ──────────────────────────────────────────────────────────────────────────
def _set_defaults(self) -> None:
"""Set default values for fields."""
if not self.date:
self.date = frappe.utils.today()
def _validate_business_rules(self) -> None:
"""Validate business rules specific to this DocType."""
if not self.title:
frappe.throw(_("Title is required"))
def _update_status(self) -> None:
"""Update status based on document state using DocStatus helper."""
# v15: Use DocStatus helper for readable status checks
if self.docstatus.is_draft() and not self.status:
self.status = "Draft"
def _notify_creation(self) -> None:
"""Send notifications after creation."""
# frappe.publish_realtime("new_<doctype>", {"name": self.name})
pass
def _validate_submit_conditions(self) -> None:
"""Check all conditions required for submission."""
pass
def _process_submission(self) -> None:
"""Process document submission - create GL entries, update stocks, etc."""
self.db_set("status", "Completed")
def _validate_cancel_conditions(self) -> None:
"""Check if document can be cancelled."""
pass
def _process_cancellation(self) -> None:
"""Reverse submission effects."""
self.db_set("status", "Cancelled")
# ──────────────────────────────────────────────────────────────────────────
# Public API Methods (call from services or whitelisted methods)
# ──────────────────────────────────────────────────────────────────────────
def get_summary(self) -> dict:
"""Return document summary for API responses."""
return {
"name": self.name,
"title": self.title,
"status": self.status,
"date": str(self.date)
}
──────────────────────────────────────────────────────────────────────────────
Whitelisted Methods (accessible via REST API)
──────────────────────────────────────────────────────────────────────────────
@frappe.whitelist() def get_<doctype_snake>_summary(name: str) -> dict: """ Get document summary.
Args:
name: Document name
Returns:
Document summary dict
"""
doc = frappe.get_doc("<DocType Name>", name)
doc.check_permission("read")
return doc.get_summary()
Step 5: Generate Service Layer
Create <app>/<module>/services/<doctype_snake>_service.py :
""" <DocType Name> Service
Business logic for <DocType Name> operations. """
import frappe from frappe import _ from typing import Optional from <app>.<module>.services.base import BaseService from <app>.<module>.repositories.<doctype_snake>_repository import <DocTypeName>Repository
class <DocTypeName>Service(BaseService): """ Service class for <DocType Name> business logic.
All business rules and complex operations should be implemented here,
not in the DocType controller.
"""
def __init__(self):
super().__init__()
self.repo = <DocTypeName>Repository()
def create(self, data: dict) -> dict:
"""
Create a new <DocType Name>.
Args:
data: Document data
Returns:
Created document summary
Raises:
frappe.ValidationError: If validation fails
"""
self.check_permission("<DocType Name>", "create", throw=True)
self.validate_mandatory(data, ["title", "date"])
doc = self.repo.create(data)
self.log_activity("<DocType Name>", doc.name, "Created")
return doc.get_summary()
def update(self, name: str, data: dict) -> dict:
"""
Update existing <DocType Name>.
Args:
name: Document name
data: Fields to update
Returns:
Updated document summary
"""
doc = self.repo.get_or_throw(name, for_update=True)
self.check_permission("<DocType Name>", "write", doc=doc, throw=True)
# Business validation
if doc.status == "Completed":
frappe.throw(_("Cannot modify completed documents"))
doc.update(data)
doc.save()
self.log_activity("<DocType Name>", name, "Updated", data)
return doc.get_summary()
def submit(self, name: str) -> dict:
"""
Submit document for processing.
Args:
name: Document name
Returns:
Submitted document summary
"""
doc = self.repo.get_or_throw(name, for_update=True)
self.check_permission("<DocType Name>", "submit", doc=doc, throw=True)
# Pre-submit validation
self._validate_submission(doc)
doc.submit()
return doc.get_summary()
def cancel(self, name: str, reason: Optional[str] = None) -> dict:
"""
Cancel submitted document.
Args:
name: Document name
reason: Cancellation reason
Returns:
Cancelled document summary
"""
doc = self.repo.get_or_throw(name, for_update=True)
self.check_permission("<DocType Name>", "cancel", doc=doc, throw=True)
if reason:
frappe.db.set_value("<DocType Name>", name, "cancellation_reason", reason)
doc.cancel()
self.log_activity("<DocType Name>", name, "Cancelled", {"reason": reason})
return doc.get_summary()
def get_dashboard_stats(self) -> dict:
"""Get statistics for dashboard."""
return {
"total": self.repo.get_count(),
"draft": self.repo.get_count({"status": "Draft"}),
"pending": self.repo.get_count({"status": "Pending"}),
"completed": self.repo.get_count({"status": "Completed"})
}
def _validate_submission(self, doc) -> None:
"""Validate all requirements for submission."""
if doc.docstatus != 0:
frappe.throw(_("Document must be in draft state to submit"))
Step 6: Generate Repository
Create <app>/<module>/repositories/<doctype_snake>_repository.py :
""" <DocType Name> Repository
Data access layer for <DocType Name>. """
import frappe from frappe.query_builder import DocType from typing import Optional from <app>.<module>.repositories.base import BaseRepository from <app>.<module>.doctype.<doctype_folder>.<doctype_snake> import <DocTypeName>
class <DocTypeName>Repository(BaseRepository[<DocTypeName>]): """ Repository for <DocType Name> database operations. """
doctype = "<DocType Name>"
def get_by_status(
self,
status: str,
limit: int = 20,
offset: int = 0
) -> list[dict]:
"""Get documents by status."""
return self.get_list(
filters={"status": status},
fields=["name", "title", "date", "status", "owner"],
order_by="date desc",
limit=limit,
offset=offset
)
def get_recent(self, days: int = 7) -> list[dict]:
"""Get documents created in the last N days."""
from_date = frappe.utils.add_days(frappe.utils.today(), -days)
return self.get_list(
filters={"creation": [">=", from_date]},
fields=["name", "title", "date", "status", "creation"],
order_by="creation desc"
)
def search(
self,
query: str,
filters: Optional[dict] = None,
limit: int = 20
) -> list[dict]:
"""Full-text search on title and description."""
base_filters = filters or {}
base_filters["title"] = ["like", f"%{query}%"]
return self.get_list(
filters=base_filters,
fields=["name", "title", "date", "status"],
limit=limit
)
def get_with_related(self, name: str) -> dict:
"""Get document with related data."""
doc = self.get_or_throw(name)
return {
**doc.as_dict(),
# Add related data here
# "items": self._get_items(name),
# "comments": self._get_comments(name)
}
def bulk_update_status(self, names: list[str], status: str) -> int:
"""Bulk update status for multiple documents."""
dt = DocType(self.doctype)
return (
frappe.qb.update(dt)
.set(dt.status, status)
.set(dt.modified, frappe.utils.now())
.set(dt.modified_by, frappe.session.user)
.where(dt.name.isin(names))
.run()
)
Step 7: Generate Test File
Create <doctype_folder>/test_<doctype_snake>.py :
Copyright (c) <year>, <author> and contributors
For license information, please see license.txt
import frappe from frappe.tests import IntegrationTestCase, UnitTestCase from <app>.<module>.services.<doctype_snake>_service import <DocTypeName>Service
class Test<DocTypeName>(IntegrationTestCase): """Integration tests for <DocType Name>."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.service = <DocTypeName>Service()
def test_create_document(self):
"""Test document creation via service."""
data = {
"title": "Test Document",
"date": frappe.utils.today()
}
result = self.service.create(data)
self.assertIsNotNone(result.get("name"))
self.assertEqual(result.get("title"), "Test Document")
def test_create_requires_mandatory_fields(self):
"""Test that mandatory fields are validated."""
with self.assertRaises(frappe.ValidationError):
self.service.create({})
def test_submit_document(self):
"""Test document submission."""
# Create draft
doc = frappe.get_doc({
"doctype": "<DocType Name>",
"title": "Submit Test",
"date": frappe.utils.today()
}).insert()
# Submit via service
result = self.service.submit(doc.name)
self.assertEqual(result.get("status"), "Completed")
def test_cannot_modify_completed(self):
"""Test that completed documents cannot be modified."""
doc = frappe.get_doc({
"doctype": "<DocType Name>",
"title": "Completed Test",
"date": frappe.utils.today(),
"status": "Completed"
}).insert()
with self.assertRaises(frappe.ValidationError):
self.service.update(doc.name, {"title": "New Title"})
def test_get_dashboard_stats(self):
"""Test dashboard statistics."""
stats = self.service.get_dashboard_stats()
self.assertIn("total", stats)
self.assertIn("draft", stats)
self.assertIn("completed", stats)
class Unit<DocTypeName>(UnitTestCase): """Unit tests for <DocType Name> (no database)."""
def test_validation_logic(self):
"""Test validation without database."""
pass
Step 8: Show Preview and Confirm
DocType Creation Preview
DocType: <DocType Name> Module: <Module> Type: Standard | Submittable | Child Table
Files to Create:
📁 <module>/doctype/<doctype_folder>/ ├── 📄 <doctype_snake>.json # DocType definition ├── 📄 <doctype_snake>.py # Controller with hooks ├── 📄 <doctype_snake>.js # Client-side script └── 📄 test_<doctype_snake>.py # Test cases
📁 <module>/services/ └── 📄 <doctype_snake>_service.py # Business logic
📁 <module>/repositories/ └── 📄 <doctype_snake>_repository.py # Data access
Fields:
| Field | Type | Required |
|---|---|---|
| naming_series | Select | Yes |
| title | Data | Yes |
| status | Select | No |
| date | Date | Yes |
| description | Text Editor | No |
Create this DocType with all layers?
Step 9: Execute and Verify
After approval, create all files and run:
bench --site <site> migrate bench --site <site> run-tests --doctype "<DocType Name>"
Output Format
DocType Created
Name: <DocType Name> Path: <app>/<module>/doctype/<doctype_folder>/
Files Created:
- ✅ <doctype_snake>.json
- ✅ <doctype_snake>.py (controller)
- ✅ <doctype_snake>.js (client)
- ✅ test_<doctype_snake>.py
- ✅ <doctype_snake>_service.py
- ✅ <doctype_snake>_repository.py
Next Steps:
- Run
bench --site <site> migrateto create database table - Add permissions in DocType settings
- Create any child tables needed
- Run tests:
bench --site <site> run-tests --doctype "<DocType Name>"
Rules
-
v15 Type Annotations — Always include TYPE_CHECKING block with type hints
-
Multi-Layer Pattern — Create service and repository for every DocType
-
No Business Logic in Controller — Controllers call services, services implement logic
-
Comprehensive Tests — Every DocType must have test coverage
-
Proper Naming — DocType folder/file names must be snake_case
-
ALWAYS Confirm — Never create files without explicit user approval
-
Index Planning — Add indexes for frequently filtered fields