Creating Modules Guide¶
Build an apcore module from scratch.
Four Module Definition Approaches¶
apcore supports four ways to create modules - choose the one that fits your scenario:
| Approach | Use Case | Code Intrusiveness | Jump To |
|---|---|---|---|
| Class-based (Class Definition) | New module development | High (inherits base class) | Quick Start |
@module Decorator |
Functions where you can modify source code | Low (add one line decorator) | module() Registration |
module() Function Call |
Wrapping existing classes/methods | Very Low (no changes to original function) | module() Registration |
| External Binding (External Binding) | Zero-modification integration of existing apps | None (no source code changes) | External Schema Binding |
Quick Start¶
1. Create Project Structure¶
my-project/
├── apcore.yaml # Framework configuration
├── extensions/ # Extensions directory
│ └── executor/ # Execution layer
│ └── email/ # Email functionality
│ └── send_email.py # Module file
└── schemas/ # Schema definitions (optional, used for cross-language)
2. Create Module File¶
# extensions/executor/email/send_email.py
from pydantic import BaseModel, Field
from apcore import Module, Context
# Step 1: Define input Schema
class SendEmailInput(BaseModel):
"""Input parameters for sending email"""
to: str = Field(..., description="Recipient email address")
subject: str = Field(..., description="Email subject")
body: str = Field(..., description="Email body")
# Step 2: Define output Schema
class SendEmailOutput(BaseModel):
"""Output result for sending email"""
success: bool = Field(..., description="Whether successful")
message_id: str | None = Field(None, description="Message ID")
# Step 3: Create module
class SendEmailModule(Module):
"""Send email module""" # This is the description
input_schema = SendEmailInput
output_schema = SendEmailOutput
def execute(self, inputs: dict, context: Context) -> dict:
# Implement sending logic
to = inputs["to"]
subject = inputs["subject"]
body = inputs["body"]
# ... send email ...
message_id = "msg_123"
return {"success": True, "message_id": message_id}
3. Module ID Auto-Generation¶
No configuration needed - the file path is the ID.
Detailed Steps¶
Step 1: Design Schema¶
First think about module inputs and outputs:
| Question | Example (Send Email) |
|---|---|
| What inputs are needed? | to, subject, body, cc |
| What outputs are returned? | success, message_id, error |
| What constraints exist? | to must be email format, subject max 200 chars |
Define Input Schema:
from pydantic import BaseModel, Field
from typing import Literal
class SendEmailInput(BaseModel):
"""Input parameters - each field must have description"""
to: str = Field(
..., # ... means required
description="Recipient email address", # AI uses this to understand
pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$" # Email format validation
)
subject: str = Field(
...,
description="Email subject",
max_length=200 # Length limit
)
body: str = Field(
...,
description="Email body, supports plain text or HTML"
)
cc: list[str] = Field(
default=[], # Optional fields must have defaults
description="CC list"
)
priority: Literal["low", "normal", "high"] = Field(
default="normal",
description="Email priority"
)
Define Output Schema:
class SendEmailOutput(BaseModel):
"""Output result"""
success: bool = Field(
...,
description="Whether email was sent successfully"
)
message_id: str | None = Field(
None,
description="Message ID when send is successful"
)
error: str | None = Field(
None,
description="Error message when send fails"
)
sent_at: str | None = Field(
None,
description="Send time, ISO 8601 format"
)
Step 2: Implement Module¶
from apcore import Module, Context
from datetime import datetime
class SendEmailModule(Module):
"""
Send email module
Send emails via SMTP or API, supports HTML format.
"""
# Associate Schema
input_schema = SendEmailInput
output_schema = SendEmailOutput
# Optional: Module metadata
name = "Send Email"
tags = ["email", "notification"]
version = "1.0.0"
def execute(self, inputs: dict, context: Context) -> dict:
"""
Execute email sending
Args:
inputs: Input parameters (already validated)
context: Call context
Returns:
Send result
"""
# Method 1: Use dict directly
to = inputs["to"]
subject = inputs["subject"]
# Method 2: Convert to Pydantic object (get type hints)
params = self.input_schema(**inputs)
try:
# Execute send
message_id = self._send_email(
to=params.to,
subject=params.subject,
body=params.body,
cc=params.cc
)
return {
"success": True,
"message_id": message_id,
"error": None,
"sent_at": datetime.now().isoformat()
}
except Exception as e:
return {
"success": False,
"message_id": None,
"error": str(e),
"sent_at": None
}
def _send_email(self, to: str, subject: str, body: str, cc: list[str]) -> str:
"""Internal method: actual sending logic"""
# Implement specific sending logic here
# Can use smtplib, etc.
return "msg_" + datetime.now().strftime("%Y%m%d%H%M%S")
Step 3: Place Files¶
Organize directories by functional layers:
extensions/
├── api/ # API entry layer
│ └── handler/
│ └── user_api.py
│
├── orchestrator/ # Orchestration layer
│ └── workflow/
│ └── user_register.py
│
├── executor/ # Execution layer
│ ├── email/
│ │ ├── send_email.py → executor.email.send_email
│ │ └── send_template.py → executor.email.send_template
│ ├── sms/
│ │ └── send_sms.py → executor.sms.send_sms
│ └── database/
│ └── query.py → executor.database.query
│
└── common/ # Common components
└── util/
└── validator.py → common.util.validator
Layer Recommendations:
| Layer | Responsibility | Examples |
|---|---|---|
api |
External request entry | HTTP handler, GraphQL resolver |
orchestrator |
Business orchestration, flow control | Registration flow, order processing |
executor |
Concrete execution, external calls | Send email, call API, query database |
common |
Common utilities | Validators, formatters |
Step 4: Use Module¶
from apcore import Registry, Executor
# 1. Create Registry and discover modules
registry = Registry(extensions_dir="./extensions")
registry.discover()
# 2. Create Executor
executor = Executor(registry)
# 3. Call module
result = executor.call(
module_id="executor.email.send_email",
inputs={
"to": "[email protected]",
"subject": "Hello",
"body": "World"
}
)
print(result)
# {"success": True, "message_id": "msg_123", ...}
Advanced Usage¶
Using Context¶
class SendEmailModule(Module):
def execute(self, inputs: dict, context: Context) -> dict:
# Get call chain information
print(f"Trace ID: {context.trace_id}")
print(f"Caller: {context.caller_id}")
print(f"Call Chain: {context.call_chain}")
# Get identity information (if available)
if context.identity:
print(f"Identity: {context.identity.id} ({context.identity.type})")
# Use shared data
custom_data = context.data.get("my_data")
# ... execute logic ...
Calling Other Modules¶
class UserRegisterModule(Module):
"""User registration module"""
def execute(self, inputs: dict, context: Context) -> dict:
# Create user
user_id = self._create_user(inputs)
# Call send email module
email_result = context.executor.call(
module_id="executor.email.send_email",
inputs={
"to": inputs["email"],
"subject": "Hello",
"body": "World"
},
context=context # Pass context to maintain call chain
)
return {
"user_id": user_id,
"email_sent": email_result["success"]
}
Async Modules¶
import aiohttp
class SendEmailModule(Module):
"""Send email module with async support"""
input_schema = SendEmailInput
output_schema = SendEmailOutput
# Define a single async execute; the framework auto-detects it
async def execute(self, inputs: dict, context: Context) -> dict:
"""Async version (preferred)"""
params = self.input_schema(**inputs)
async with aiohttp.ClientSession() as session:
# Send asynchronously
message_id = await self._send_async(session, params)
return {
"success": True,
"message_id": message_id,
"error": None
}
Resource Management¶
class DatabaseModule(Module):
"""Database module that manages connections"""
_pool: Any = None # Connection pool
def on_load(self) -> None:
"""Create connection pool when module loads"""
self._pool = create_connection_pool(
host="localhost",
database="mydb"
)
def on_unload(self) -> None:
"""Close connection pool when module unloads"""
if self._pool:
self._pool.close()
def execute(self, inputs: dict, context: Context) -> dict:
# Use connection pool
with self._pool.get_connection() as conn:
result = conn.execute(inputs["sql"])
return {"rows": result}
Common Patterns¶
Pattern 1: Simple Executor¶
class CalculatorModule(Module):
"""Simple calculator - no side effects"""
class Input(BaseModel):
a: float = Field(..., description="First number")
b: float = Field(..., description="Second number")
op: Literal["+", "-", "*", "/"] = Field(..., description="Operator")
class Output(BaseModel):
result: float = Field(..., description="Calculation result")
input_schema = Input
output_schema = Output
def execute(self, inputs: dict, context: Context) -> dict:
a, b, op = inputs["a"], inputs["b"], inputs["op"]
ops = {"+": a + b, "-": a - b, "*": a * b, "/": a / b}
return {"result": ops[op]}
Pattern 2: External API Call¶
class WeatherModule(Module):
"""Get weather information - calls external API"""
class Input(BaseModel):
city: str = Field(..., description="City name")
class Output(BaseModel):
temperature: float = Field(..., description="Temperature (Celsius)")
description: str = Field(..., description="Weather description")
input_schema = Input
output_schema = Output
def execute(self, inputs: dict, context: Context) -> dict:
import requests
response = requests.get(
"https://api.weather.com/v1/current",
params={"city": inputs["city"]}
)
data = response.json()
return {
"temperature": data["temp"],
"description": data["desc"]
}
Pattern 3: Data Validator¶
class EmailValidatorModule(Module):
"""Email format validator"""
class Input(BaseModel):
email: str = Field(..., description="Email to validate")
class Output(BaseModel):
valid: bool = Field(..., description="Whether valid")
reason: str | None = Field(None, description="Reason if invalid")
input_schema = Input
output_schema = Output
def execute(self, inputs: dict, context: Context) -> dict:
import re
email = inputs["email"]
pattern = r"^[\w\.-]+@[\w\.-]+\.\w+$"
if re.match(pattern, email):
return {"valid": True, "reason": None}
else:
return {"valid": False, "reason": "Invalid email format"}
Pattern 4: Executor with Retry¶
from tenacity import retry, stop_after_attempt, wait_exponential
class ReliableSendModule(Module):
"""Reliable send with retry"""
input_schema = SendEmailInput
output_schema = SendEmailOutput
def execute(self, inputs: dict, context: Context) -> dict:
try:
return self._execute_with_retry(inputs)
except Exception as e:
return {"success": False, "message_id": None, "error": str(e)}
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
def _execute_with_retry(self, inputs: dict) -> dict:
# This method will automatically retry
message_id = self._send(inputs)
return {"success": True, "message_id": message_id, "error": None}
Best Practices¶
1. Schema Design¶
# ✅ Good design: fields have descriptions and constraints
class GoodInput(BaseModel):
email: str = Field(..., description="User email", pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")
age: int = Field(..., description="User age", ge=0, le=150)
# ❌ Bad design: missing descriptions and constraints
class BadInput(BaseModel):
email: str
age: int
2. Error Handling¶
# ✅ Good practice: return structured errors
def execute(self, inputs: dict, context: Context) -> dict:
try:
result = self._do_work(inputs)
return {"success": True, "data": result, "error": None}
except ValidationError as e:
return {"success": False, "data": None, "error": f"Parameter error: {e}"}
except ExternalAPIError as e:
return {"success": False, "data": None, "error": f"External service error: {e}"}
# ❌ Bad practice: throw exceptions directly
def execute(self, inputs: dict, context: Context) -> dict:
return self._do_work(inputs) # Exception propagates up
3. Single Responsibility¶
# ✅ Good design: each module does one thing
class SendEmailModule(Module): ... # Only sends email
class ValidateEmailModule(Module): ... # Only validates email
class RenderTemplateModule(Module): ... # Only renders template
# ❌ Bad design: one module does too much
class EmailModule(Module):
def execute(self, inputs: dict, context: Context) -> dict:
# Validation, rendering, sending all together
self._validate(inputs)
html = self._render(inputs)
return self._send(html)
Testing Guide¶
Basic Module Testing¶
# test_send_email.py
import pytest
from unittest.mock import MagicMock
from apcore import Context, Identity
def create_test_context(**kwargs):
"""Create test Context"""
return Context(
trace_id="test-trace-id",
caller_id=kwargs.get("caller_id"),
call_chain=kwargs.get("call_chain", []),
executor=kwargs.get("executor", MagicMock()),
identity=kwargs.get("identity", Identity(id="test", type="user")),
data=kwargs.get("data", {})
)
class TestSendEmailModule:
def setup_method(self):
self.module = SendEmailModule()
self.context = create_test_context()
def test_successful_send(self):
"""Test successful send"""
result = self.module.execute(
inputs={
"to": "[email protected]",
"subject": "Test",
"body": "Hello"
},
context=self.context
)
assert result["success"] is True
def test_invalid_input(self):
"""Test invalid input"""
with pytest.raises(Exception):
self.module.execute(
inputs={"to": "", "subject": ""},
context=self.context
)
def test_calls_other_module(self):
"""Test inter-module calls (Mock Executor)"""
mock_executor = MagicMock()
mock_executor.call.return_value = {"result": "ok"}
context = create_test_context(executor=mock_executor)
result = self.module.execute(
inputs={...},
context=context
)
mock_executor.call.assert_called_once_with(
module_id="target.module",
inputs={...},
context=context
)
Debugging Tips¶
- Use trace_id to track call chain: Search for
trace_idin logs to trace complete call path - Check call_chain:
context.call_chainshows the complete path of current call - Pre-validate Schema: Use
executor.validate()to check if inputs are valid before execution - Middleware debugging: Add
LoggingMiddlewareto view inputs/outputs of each call
Performance Guide¶
| Recommendation | Explanation |
|---|---|
| Reuse connections | Create connection pool in on_load(), close in on_unload() |
| Avoid blocking | Long operations should use execute_async() |
| Control data size | context.data is shared along call chain, avoid storing large amounts of data |
| Set timeouts | External calls must set reasonable timeout |
| Idempotent design | Modules marked as idempotent=True should ensure repeated calls are safe |
module() Registration¶
For existing functions or methods, wrap them as standard apcore modules using the
@moduledecorator ormodule()function call. See PROTOCOL_SPEC §5.11 for detailed specification.
@module Decorator (Simple Example)¶
Before (regular function):
def send_email(to: str, subject: str, body: str) -> dict:
"""Send email"""
# Business logic...
return {"success": True, "message_id": "msg_123"}
After (apcore module):
from apcore import module
@module(id="email.send", tags=["email", "notification"])
def send_email(to: str, subject: str, body: str) -> dict:
"""Send email"""
# Business logic completely unchanged
return {"success": True, "message_id": "msg_123"}
Add one line of @module decorator, and the function automatically becomes an apcore module:
- Schema is auto-generated from type annotations
- Description is auto-extracted from docstring
- Module is auto-registered to Registry
module() Function Call (Register existing class methods)¶
from apcore import module
# Existing business code (no modifications)
class EmailService:
def send(self, to: str, subject: str, body: str) -> dict:
"""Send email"""
return {"success": True}
def send_template(self, template_id: str, data: dict) -> dict:
"""Send using template"""
return {"success": True}
# Register via module() without changing original code
service = EmailService()
module(service.send, id="email.send")
module(service.send_template, id="email.send_template")
Advanced Example (Annotated + async)¶
from apcore import module, Context, ModuleAnnotations
from typing import Annotated
from pydantic import Field
@module(
id="email.send",
annotations=ModuleAnnotations(open_world=True, idempotent=False),
tags=["email"]
)
async def send_email(
to: Annotated[str, Field(description="Recipient email", pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")],
subject: Annotated[str, Field(description="Email subject", max_length=200)],
body: Annotated[str, Field(description="Email body")],
cc: Annotated[list[str], Field(description="CC list")] = [],
context: Context = None
) -> dict:
"""
Send email module
Send emails asynchronously via SMTP.
"""
# async def automatically maps to execute_async
print(f"trace_id: {context.trace_id}")
return {"success": True, "message_id": "msg_123"}
ID Generation Rules¶
- When
idparameter is specified: use it directly - When not specified: auto-generate from function's
__module__+__qualname__
Description Extraction¶
descriptionparameter (highest priority)- First line of function docstring
- Default description generated from function name
Limitations¶
| Feature | Class-based | module() |
|---|---|---|
| Lifecycle hooks (on_load/on_unload) | Supported | Not supported |
| Custom validate() | Supported | Not supported |
| Schema source | Pydantic Model | Auto-generated from type annotations |
| Execution context | self + context |
context parameter injection |
External Schema Binding (YAML)¶
For scenarios where you cannot modify existing source code at all, use YAML binding files to map functions to apcore modules. See PROTOCOL_SPEC §5.12 for detailed specification.
Complete Binding File Example¶
# bindings/email.binding.yaml
bindings:
- module_id: "email.send"
target: "myapp.services.email:send_email"
description: "Send email"
tags: ["email", "notification"]
annotations:
open_world: true
idempotent: false
input_schema:
type: object
properties:
to:
type: string
description: "Recipient email"
subject:
type: string
description: "Email subject"
body:
type: string
description: "Email body"
required: [to, subject, body]
output_schema:
type: object
properties:
success:
type: boolean
message_id:
type: string
required: [success]
- module_id: "email.send_template"
target: "myapp.services.email:EmailService.send_template"
description: "Send email using template"
auto_schema: true
auto_schema Mode¶
When the target function has complete type annotations, you can use auto_schema: true to auto-generate Schema:
bindings:
- module_id: "email.send"
target: "myapp.services.email:send_email"
auto_schema: true # Auto-generate from send_email's type annotations
Equivalent to module(send_email, id="email.send"), but requires no source code modifications.
Discovery Mechanism Configuration¶
# apcore.yaml
bindings:
dir: "./bindings" # Scan directory (default)
pattern: "*.binding.yaml" # File matching pattern
# Or specify file list
files:
- "./bindings/email.binding.yaml"
- "./bindings/payment.binding.yaml"
Multiple Binding Files Management¶
my-project/
├── bindings/
│ ├── email.binding.yaml # Email-related modules
│ ├── payment.binding.yaml # Payment-related modules
│ └── user.binding.yaml # User-related modules
└── apcore.yaml
Each binding file is organized by business domain. The framework automatically scans all *.binding.yaml files in the bindings/ directory.
Approach Selection Comparison¶
| Consideration | Class-based | @module Decorator |
module() Function Call |
External Binding |
|---|---|---|---|---|
| New development | Recommended | Usable | Usable | Not recommended |
| Wrap existing functions | Not recommended (requires rewrite) | Recommended | Recommended | Usable |
| Cannot modify source | Impossible | Impossible | Impossible | Recommended |
| Need lifecycle management | Recommended | Not supported | Not supported | Not supported |
| Cross-language unified config | Not applicable | Not applicable | Partially applicable | Recommended |
| Schema flexibility | Highest (Pydantic) | Medium (type annotations) | Medium (type annotations) | High (hand-written YAML) |
Next Steps¶
- Schema Definition Details - Complete Schema usage
- ACL Configuration Guide - Configure module access permissions
- Module Interface Definition - API reference
- Adapter Development Guide - Framework adapter development