Best Practices
Guidelines for building maintainable, performant Fullfinity applications.
Module Organization
Section titled “Module Organization”File Structure
Section titled “File Structure”my_module/├── __init__.py # Package marker (optional but recommended)├── manifest.yaml # Module metadata├── models/│ ├── __init__.py│ ├── main_model.py # One model per file│ └── related_model.py├── views/│ ├── main_views.yaml # Views per model│ ├── menus.yaml # Menu items│ └── actions.yaml # Window actions├── security/│ ├── groups.yaml # Security groups│ ├── access.yaml # Model access rules│ └── rules.yaml # Record rules└── data/ └── initial_data.yaml # Default dataNaming Conventions
Section titled “Naming Conventions”| Item | Convention | Example |
|---|---|---|
| Module folder | snake_case | sales_crm |
| Model class | PascalCase | SaleOrder |
Model _name | PascalCase | "SaleOrder" |
| Field name | snake_case | expected_revenue |
| View identifier | snake_case | sale_order_form_view |
| Menu identifier | snake_case | sale_order_menu |
Model Design
Section titled “Model Design”Keep Models Focused
Section titled “Keep Models Focused”# Good: Single responsibilityclass SaleOrder(Model): """Manages sales orders.""" pass
class SaleOrderLine(Model): """Individual line items on orders.""" pass
# Bad: God modelclass Sale(Model): """Everything sales-related.""" # 50+ fields, multiple concerns passUse Calculated Fields
Section titled “Use Calculated Fields”# Good: Computed from source dataclass SaleOrder(Model): lines = OneToMany(related_model="SaleOrderLine", related_name="order") total = Monetary(calculate="compute_total", store=True)
@Model.calculate("lines", "lines__subtotal") async def compute_total(self): for record in self: await record.fetch_related("lines") record.total = sum(line.subtotal for line in (record.lines or []))
# Bad: Manually updatedtotal = Monetary() # Must remember to updatePrefer ManyToOne Over Denormalization
Section titled “Prefer ManyToOne Over Denormalization”# Good: Normalized relationshipcustomer = ManyToOne("Contact", related_name="orders", on_delete="SET NULL")
# Access via relationshiporder = await SaleOrder.filter(id=order_id).first()await order.fetch_related("customer")print(order.customer.name)
# Bad: Duplicated datacustomer_name = Char(max_length=255)customer_email = Char(max_length=255)customer_phone = Char(max_length=50)Default Values
Section titled “Default Values”# Good: Sensible defaultsactive = Boolean(default=True)state = Selection(choices=["Draft", "Confirmed", "Done"], default="Draft")date = Date(default=lambda self: date.today())
# Good: Computed default using lambda (get_current_user is a helper from# fullfinity.engine.base, not a Model method)salesperson = ManyToOne( "User", related_name="orders", on_delete="SET NULL", default=lambda self: get_current_user(self))View Design
Section titled “View Design”Use Semantic Targeting
Section titled “Use Semantic Targeting”# Good: Semantic targeting- action: add target: field: email position: after value: {...}
# Bad: Index-based (fragile)- action: add target: arch[1].content[0].content[5] value: {...}Add Anchors for Extension Points
Section titled “Add Anchors for Extension Points”# Good: Provides stable extension points- type: row anchor: customer_info_section content: [...]
# Extensions can target this:- target: anchor: customer_info_sectionConsistent Widget Usage
Section titled “Consistent Widget Usage”# Status fields: Badge- type: field name: state properties: widget: Badge colors: {...}
# Boolean toggles: Switch (not `active` — archiving is the three-dot menu, never a form toggle)- type: field name: is_default properties: widget: Switch
# Currency: Monetary (renders the currency symbol; needs a currency_field)- type: field name: amount properties: widget: Monetary currency_field: currencySecurity
Section titled “Security”Principle of Least Privilege
Section titled “Principle of Least Privilege”# Good: Start restrictive- data_type: ModelAccess model: SaleOrder group: sales_user_group read_perm: true create_perm: true write_perm: true delete_perm: false # Users can't delete
# Managers get delete- data_type: ModelAccess model: SaleOrder group: sales_manager_group read_perm: true create_perm: true write_perm: true delete_perm: trueAlways Define Record Rules
Section titled “Always Define Record Rules”# Good: Users see only their records- data_type: RecordRule identifier: sale_order_own_rule name: 'Sale Order: Own' model: SaleOrder groups: - - R - - sales_user_group rule: Q(salesperson__id=uid) read_perm: true write_perm: true create_perm: true delete_perm: true
# Managers see all- data_type: RecordRule identifier: sale_order_all_rule name: 'Sale Order: All' model: SaleOrder groups: - - R - - sales_manager_group rule: Q(id__gt=0) read_perm: true write_perm: true create_perm: true delete_perm: trueUse Groups for UI Elements
Section titled “Use Groups for UI Elements”# Good: Hide manager-only buttons- type: actionButton anchor: action_override_price properties: label: Override Price method: override_price groups: [sales_manager_group]Use Controlled Edits for State-Based Restrictions
Section titled “Use Controlled Edits for State-Based Restrictions”# Good: Lock fields after confirmationclass SaleOrder(Model): _controlled_edits = [ { "id": "confirmed", "condition": "Q(state__in=['Confirmed', 'Done'])", "exclusions": ["note", "internal_note"], "message": "This order is confirmed. Cancel it to change the lines.", }, ]This ensures:
- Fields are locked when the record reaches certain states
- Enforced at both backend (API) and frontend (UI) from the same declaration
- Only the rule’s exclusions remain editable
- Cannot be bypassed by direct API calls
- Another module can add its own rule, or override this one by its
id, without either of you restating the other’s condition — see Controlled Edits
Performance
Section titled “Performance”Avoid N+1 Queries
Section titled “Avoid N+1 Queries”# Bad: N+1 queriesorders = await SaleOrder.filter().all()for order in orders: await order.fetch_related("customer") # Query per order
# Good: Prefetch related dataorders = await SaleOrder.filter().prefetch_related("customer").all()for order in orders: customer = order.customer # Already loadedUse Search Limits
Section titled “Use Search Limits”# Good: Paginate resultsorders = await SaleOrder.filter(state="Draft").limit(50).offset(0).all()
# Bad: Load everythingall_orders = await SaleOrder.filter().all() # Could be millionsIndex Frequently Queried Fields
Section titled “Index Frequently Queried Fields”class SaleOrder(Model): # Fields used in filters/sorting should be indexed state = Selection(choices=["Draft", "Confirmed", "Done"], index=True) date = Date(index=True) customer = ManyToOne("Contact", related_name="orders", on_delete="SET NULL", index=True)Error Handling
Section titled “Error Handling”User-Friendly Errors
Section titled “User-Friendly Errors”from fullfinity.engine.base import UserError, ValidationError
async def confirm_order(self): if not self.lines: raise UserError("Cannot confirm order without lines")
if self.total <= 0: raise ValidationError("Order total must be positive")Validate Before Save
Section titled “Validate Before Save”class SaleOrder(Model): @Model.validate("discount") async def check_discount(self): if self.discount > 50: raise ValidationError("Discount cannot exceed 50%")Testing
Section titled “Testing”Test Security Rules
Section titled “Test Security Rules”class TestOrderSecurity(TestCase): async def test_user_sees_own_orders(self): User = get_model("User") other = await User.create(name="Other", email="other@test.fullfinity")
# act_as_role creates/reuses a single user holding the given role; grab it so # we can make an order it owns. Fixtures are staged elevated (outside the block). async with self.act_as_role("sales_user_group"): me = type(self).env.user own_order = await SaleOrder.create(salesperson=me.id) other_order = await SaleOrder.create(salesperson=other.id)
# Re-enter as that same role user with ACL enforced, so the record rule applies. async with self.act_as_role("sales_user_group"): orders = await SaleOrder.filter().all()
self.assertEqual(len(orders), 1) self.assertEqual(orders[0].id, own_order.id)Test Calculated Fields
Section titled “Test Calculated Fields”async def test_order_total(): order = await SaleOrder.create(name="SO-TEST") await SaleOrderLine.create(order=order.id, subtotal=100) await SaleOrderLine.create(order=order.id, subtotal=50)
# Reload from the database to pick up the recomputed stored total reloaded = await SaleOrder.filter(id=order.id).first() assert reloaded.total == 150Documentation
Section titled “Documentation”Document Model Purpose
Section titled “Document Model Purpose”class SaleOrder(Model): """ Sales Order manages customer orders.
Workflow: - Draft: Initial state, can be edited - Confirmed: Locked for processing - Done: Completed and delivered - Cancelled: Order cancelled
Related Models: - SaleOrderLine: Individual line items - Contact: Customer information - Product: Ordered products """Document Complex Fields
Section titled “Document Complex Fields”probability = Integer( description="Probability (%)", hint="Likelihood of closing this opportunity. " "Updated automatically based on stage, " "can be manually overridden.")Common Mistakes
Section titled “Common Mistakes”Don’t Bypass Security
Section titled “Don’t Bypass Security”from fullfinity.engine.models import elevate
# Bad: Always using elevate()with elevate(): orders = await SaleOrder.filter().all()
# Good: Use elevate() only when necessarywith elevate(): public_info = await Product.filter(public=True).all()Don’t Hardcode IDs
Section titled “Don’t Hardcode IDs”# Bad: Hardcoded stage IDawait lead.update(stage=5)
# Good: Look the record up by its seed identifierCrmStage = get_model("CrmStage")won_stage = await CrmStage.filter(identifier="stage_won").first()await lead.update(stage=won_stage.id)Don’t Mix Concerns
Section titled “Don’t Mix Concerns”# Bad: Model doing view logicclass SaleOrder(Model): def get_kanban_color(self): return "red" if self.urgent else "blue"# Good: Keep in view definition- type: field name: priority properties: widget: Badge colors: urgent: red normal: blueSummary
Section titled “Summary”- Organize - Clear module structure, consistent naming
- Design - Focused models, calculated fields, relationships
- Secure - Least privilege, record rules, group-based UI
- Optimize - Avoid N+1, use limits, index wisely
- Test - Security, calculated fields, workflows
- Document - Model purpose, complex fields, workflows