frappe-syntax-controllers

$npx mdskill add Impertio-Studio/Frappe_Claude_Skill_Package/frappe-syntax-controllers

Build server-side DocType logic with Python Document Controllers.

  • Automate ERPNext document validation and lifecycle hooks.
  • Integrates with Frappe v14-v16 and Python DocType classes.
  • Executes custom naming, validation, and update logic.
  • Returns executable Python code for immediate deployment.

SKILL.md

.github/skills/frappe-syntax-controllersView on GitHub ↗
---
name: frappe-syntax-controllers
description: >
  Use when writing Python Document Controllers for ERPNext/Frappe DocTypes.
  Covers lifecycle hooks (validate, on_update, on_submit), controller
  override, submittable documents, autoname patterns, UUID naming (v16),
  and the flags system. Keywords: document controller, lifecycle hook,
  validate, on_update, on_submit, autoname, naming series, flags, v14-v16,
  controller example, lifecycle hook order, when to use validate, Python DocType class.
license: MIT
compatibility: "Claude Code, Claude.ai Projects, Claude API. Frappe v14-v16."
metadata:
  author: OpenAEC-Foundation
  version: "2.0"
---

# Frappe Syntax: Document Controllers

Document Controllers are Python classes that define all server-side logic for a DocType.
EVERY DocType has exactly one controller file. The controller class extends `frappe.model.document.Document`.

## Quick Reference

```python
import frappe
from frappe import _
from frappe.model.document import Document

class SalesOrder(Document):
    def autoname(self):
        """Custom naming logic. Sets self.name."""
        self.name = f"SO-{self.customer_code}-{frappe.utils.now_datetime().year}"

    def validate(self):
        """MAIN validation — runs on EVERY save (insert and update).
        Changes to self ARE saved to database."""
        if not self.items:
            frappe.throw(_("Items are required"))
        self.total = sum(item.amount for item in self.items)

    def on_update(self):
        """After save — changes to self are NOT saved.
        Use frappe.db.set_value() for post-save field changes."""
        self.notify_linked_docs()

    def on_submit(self):
        """After submit (docstatus 0 -> 1). Create ledger entries here."""
        self.create_gl_entries()

    def on_cancel(self):
        """After cancel (docstatus 1 -> 2). Reverse ledger entries here."""
        self.reverse_gl_entries()

    @frappe.whitelist()
    def recalculate(self):
        """Exposed to client JS via frm.call('recalculate')."""
        self.total = sum(item.amount for item in self.items)
        return {"total": self.total}
```

### File Location and Naming

| DocType Name | Class Name | File Path |
|---|---|---|
| Sales Order | `SalesOrder` | `selling/doctype/sales_order/sales_order.py` |
| My Custom Doc | `MyCustomDoc` | `module/doctype/my_custom_doc/my_custom_doc.py` |

**Rule**: DocType name -> PascalCase class -> snake_case filename. ALWAYS match exactly.

---

## Lifecycle Hook Execution Order

### INSERT (new document)

```
before_insert -> before_naming -> autoname -> before_validate -> validate
-> before_save -> [db_insert] -> after_insert -> on_update -> on_change
```

### SAVE (existing document)

```
before_validate -> validate -> before_save -> [db_update]
-> on_update -> on_change
```

### SUBMIT (docstatus 0 -> 1)

```
before_validate -> validate -> before_submit -> [db_update]
-> on_submit -> on_update -> on_change
```

### CANCEL (docstatus 1 -> 2)

```
before_cancel -> [db_update] -> on_cancel -> on_change
```

### UPDATE AFTER SUBMIT

```
before_update_after_submit -> [db_update]
-> on_update_after_submit -> on_change
```

### DELETE

```
on_trash -> [db_delete] -> after_delete
```

### DISCARD [v15+]

```
before_discard -> [db_set docstatus=2] -> on_discard
```

**Complete hook reference with parameters**: See [lifecycle-methods.md](references/lifecycle-methods.md)

---

## Hook Selection Decision Tree

```
What do you need to do?
|
+-- Validate data or calculate fields?
|   +-- validate (changes to self ARE saved)
|
+-- Action AFTER save (emails, sync, linked docs)?
|   +-- on_update (changes to self are NOT saved)
|
+-- Only for NEW documents?
|   +-- after_insert (runs once on first save only)
|
+-- Custom document name?
|   +-- autoname (set self.name)
|
+-- Before/after SUBMIT?
|   +-- Validate before submit? -> before_submit
|   +-- Create entries after submit? -> on_submit
|
+-- Before/after CANCEL?
|   +-- Check linked docs? -> before_cancel
|   +-- Reverse entries? -> on_cancel
|
+-- Cleanup before delete?
|   +-- on_trash
|
+-- React to ANY value change (including db_set)?
|   +-- on_change (MUST be idempotent)
```

---

## Critical Rules

### 1. Changes after on_update are NOT saved

```python
# WRONG - change is lost after on_update
def on_update(self):
    self.status = "Completed"  # NOT saved to database

# CORRECT - use db_set or frappe.db.set_value
def on_update(self):
    self.db_set("status", "Completed")
```

### 2. NEVER call frappe.db.commit() in controllers

```python
# WRONG - breaks Frappe transaction management
def validate(self):
    frappe.db.commit()  # Can cause partial updates on error

# CORRECT - Frappe commits automatically at end of request
def validate(self):
    self.update_related()  # No commit needed
```

### 3. ALWAYS call super() when overriding

```python
# WRONG - parent validation is skipped entirely
def validate(self):
    self.custom_check()

# CORRECT - parent logic preserved
def validate(self):
    super().validate()
    self.custom_check()
```

### 4. Use flags for recursion prevention

```python
def on_update(self):
    if self.flags.get("from_linked_doc"):
        return
    linked = frappe.get_doc("Linked Doc", self.linked_doc)
    linked.flags.from_linked_doc = True
    linked.save()
```

### 5. NEVER put validation logic in on_update

```python
# WRONG - document is already saved when this throws
def on_update(self):
    if self.total < 0:
        frappe.throw("Invalid total")  # Too late!

# CORRECT - validate BEFORE save
def validate(self):
    if self.total < 0:
        frappe.throw("Invalid total")  # Blocks save
```

---

## Document Naming (autoname)

| Method | Example | Result | Version |
|---|---|---|---|
| `field:fieldname` | `field:customer_name` | `ABC Company` | All |
| `naming_series:` | `naming_series:` | `SO-2024-00001` | All |
| Expression | `PRE-.#####` | `PRE-00001` | All |
| Old-style format | `INV-{YYYY}-{####}` | `INV-2024-0001` | Deprecated v16 |
| `hash` / `random` | `hash` | `a1b2c3d4e5` | All |
| `Prompt` | `Prompt` | User enters name | All |
| `autoincrement` | `autoincrement` | `1`, `2`, `3` | All |
| **`UUID`** | `UUID` | `550e8400-e29b-...` | **v16+** |
| Custom method | `autoname()` in controller | Any pattern | All |

### Custom autoname Method

```python
from frappe.model.naming import getseries

class Project(Document):
    def autoname(self):
        prefix = f"P-{self.customer[:3].upper()}-"
        self.name = getseries(prefix, 3)
        # Result: P-ACM-001, P-ACM-002, etc.
```

### UUID Naming [v16+]

Set `autoname = "UUID"` in DocType definition. Frappe generates UUID v4.

```
When to use UUID:              When to use traditional naming:
- Cross-system sync            - User-facing references (SO-00001)
- Bulk record creation         - Sequential numbering required
- Global uniqueness needed     - Auditing requires readable names
```

---

## Controller Extension Mechanisms

### 1. override_doctype_class (full replacement) [All versions]

```python
# hooks.py
override_doctype_class = {
    "Sales Order": "custom_app.overrides.CustomSalesOrder"
}

# custom_app/overrides.py
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder

class CustomSalesOrder(SalesOrder):
    def validate(self):
        super().validate()  # ALWAYS call super()
        self.custom_validation()
```

**WARNING**: Only ONE app can override a DocType class. Multiple overrides conflict.

### 2. extend_doctype_class (mixin, non-destructive) [v16+]

```python
# hooks.py
extend_doctype_class = {
    "Address": ["custom_app.extensions.address.GeocodingMixin"],
    "Contact": [
        "custom_app.extensions.common.ValidationMixin",
        "custom_app.extensions.contact.PhoneMixin"
    ]
}

# custom_app/extensions/address.py
from frappe.model.document import Document

class GeocodingMixin(Document):
    @property
    def full_address(self):
        return f"{self.address_line1}, {self.city}, {self.country}"

    def validate(self):
        super().validate()
        self.geocode_address()
```

**ALWAYS prefer `extend_doctype_class` over `override_doctype_class` in v16+.**
Multiple apps can safely extend the same DocType.

### 3. doc_events (hook individual events) [All versions]

```python
# hooks.py
doc_events = {
    "Sales Order": {
        "validate": "custom_app.events.validate_sales_order",
        "on_submit": "custom_app.events.on_submit_sales_order"
    },
    "*": {  # ALL DocTypes
        "after_insert": "custom_app.events.log_creation"
    }
}

# custom_app/events.py
def validate_sales_order(doc, method=None):
    if doc.total > 100000:
        doc.requires_approval = 1
```

### When to Use Which

```
Need full class replacement?     -> override_doctype_class [all versions]
Need to add methods/properties?  -> extend_doctype_class [v16+]
Need to hook one or two events?  -> doc_events [all versions]
Need to extend in v14/v15?       -> override_doctype_class or doc_events
```

---

## Whitelisted Methods

Expose controller methods to client-side JavaScript with `@frappe.whitelist()`:

```python
class SalesOrder(Document):
    @frappe.whitelist()
    def send_email(self, recipient):
        """Callable from JS: frm.call('send_email', {recipient: '...'})"""
        frappe.sendmail(recipients=[recipient], message="Order confirmed")
        return {"status": "sent"}
```

```javascript
// Client-side call
frm.call('send_email', { recipient: 'customer@example.com' })
    .then(r => frappe.msgprint(r.message.status));
```

**Rules**:
- ALWAYS add `@frappe.whitelist()` decorator — without it, the method is NOT callable from client
- The method MUST be defined on the controller class (not standalone)
- Permission checks happen automatically (user must have read access to the document)

---

## Submittable Documents

Documents with `is_submittable = 1` follow the docstatus lifecycle:

| docstatus | State | Editable | Transitions |
|---|---|---|---|
| 0 | Draft | Yes | -> 1 (Submit) |
| 1 | Submitted | Only "Allow on Submit" fields | -> 2 (Cancel) |
| 2 | Cancelled | No | None (amend creates new Draft) |

ALWAYS implement both `on_submit` and `on_cancel` as a pair.
ALWAYS reverse in `on_cancel` what `on_submit` created.

---

## Inheritance Patterns

```python
# Standard controller
from frappe.model.document import Document
class MyDoc(Document): pass

# Tree DocType (hierarchical)
from frappe.utils.nestedset import NestedSet
class Department(NestedSet):
    nsm_parent_field = "parent_department"

# Virtual DocType (no database table)
class ExternalData(Document):
    def load_from_db(self): ...
    def db_insert(self, *args, **kwargs): ...
    def db_update(self, *args, **kwargs): ...
    @staticmethod
    def get_list(args): ...
    @staticmethod
    def get_count(args): ...
```

---

## Type Annotations [v15+]

```python
class Person(Document):
    if TYPE_CHECKING:
        from frappe.types import DF
        first_name: DF.Data
        last_name: DF.Data
        birth_date: DF.Date
        company: DF.Link
```

Enable auto-generation in `hooks.py`: `export_python_type_annotations = True`

---

## Version Differences

| Feature | v14 | v15 | v16 |
|---|---|---|---|
| Type annotations | No | Auto-generated | Yes |
| `before_discard` / `on_discard` | No | Yes | Yes |
| `flags.notify_update` | No | Yes | Yes |
| `extend_doctype_class` | No | No | **Yes** |
| UUID autoname | No | No | **Yes** |
| Old-style format naming | Yes | Yes | Deprecated |

---

## Reference Files

| File | Contents |
|---|---|
| [lifecycle-methods.md](references/lifecycle-methods.md) | All hooks with execution order diagrams |
| [document-api-complete.md](references/document-api-complete.md) | **Complete Document API**: all methods by category (CRUD, fields, DB, permissions, flags, child tables, naming) |
| [methods.md](references/methods.md) | Document class method signatures |
| [events.md](references/events.md) | All document events in order |
| [examples.md](references/examples.md) | Complete working controller examples |
| [anti-patterns.md](references/anti-patterns.md) | Common mistakes and corrections |
| [flags.md](references/flags.md) | Flags system (doc.flags, frappe.flags) |
| [hooks.md](references/hooks.md) | Controller interaction with hooks.py |
| [patterns.md](references/patterns.md) | Common controller patterns |
| [syntax.md](references/syntax.md) | Controller class syntax reference |

## Related Skills

- `frappe-syntax-serverscripts` -- Server Scripts (sandbox alternative)
- `frappe-syntax-hooks` -- hooks.py configuration
- `frappe-impl-controllers` -- Implementation workflows
- `frappe-core-permissions` -- Permission system

More from Impertio-Studio/Frappe_Claude_Skill_Package