Business objects (BOs) are a fundamental pattern for enterprise application development, encapsulating data and business logic in a reusable, maintainable structure. Deciding when and how to implement business objects can make or break a project's long-term viability. This guide covers designing effective business object architecture from a senior developer's perspective.
Why Business Objects
Business objects provide:
- Abstraction: Separate business logic from infrastructure
- Reusability: Same logic across multiple interfaces
- Validation: Centralized data integrity rules
- Testability: Isolated units for testing
- Documentation: Self-describing domain model
When Business Objects Make Sense
Good Fit: Complex Enterprise Systems
Consider a document management system for large organizations:
- Multiple entity types: Documents, users, permissions, workflows
- Complex relationships: Documents belong to categories, have versions, require approvals
- Business rules: Validation, state transitions, access control
- Multiple interfaces: Web UI, mobile app, API, batch processing
Here, business objects provide:
┌─────────────────────────────────────────────────────────┐
│ User Interface │
├─────────────────────────────────────────────────────────┤
│ Business Objects │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Document │ │ Workflow │ │ User │ │
│ │ - validate│ │ - approve│ │ - auth │ │
│ │ - version │ │ - reject │ │ - perms │ │
│ └──────────┘ └──────────┘ └──────────┘ │
├─────────────────────────────────────────────────────────┤
│ Data Access Layer (ORM) │
├─────────────────────────────────────────────────────────┤
│ Database │
└─────────────────────────────────────────────────────────┘
Poor Fit: Simple Data Processing
For a quick SMS processing service:
- Simple data model: 10 fields, minimal relationships
- Straightforward operations: Receive, validate, store
- Single interface: API endpoint
- No complex business rules
Using a full BO framework (like Symfony with Propel) requires creating 40+ files for simple CRUD operations. The overhead outweighs the benefits.
Architecture Decisions
Framework vs. Custom
Use Framework When:
- Team already knows it
- Project has a long lifespan (5+ years)
- Multiple developers will maintain it
- Standard patterns fit your needs
Build Custom When:
- Unique requirements don't fit frameworks
- Performance is critical
- Team has deep domain expertise
- Simple, focused application
Code Generation Considerations
Many BO frameworks use code generators (Propel, Entity Framework, etc.):
Pros:
- Rapid initial development
- Consistent patterns
- Reduced boilerplate
Cons:
- Schema changes become painful
- Generated code often needs customization
- Updates to the generator can break extensions
- Copy-paste mentality reduces thinking
Best Practice: Prefer inheritance and composition over modifying generated code:
// Generated base class (don’t modify)
abstract class BaseDocument {
protected $id;
protected $title;
protected $content;
// Generated getters/setters
}
// Your customizations
class Document extends BaseDocument {
public function validate(): array {
$errors = [];
if (empty($this->title)) {
$errors[] = 'Title is required';
}
if (strlen($this->content) < 100) {
$errors[] = 'Content must be at least 100 characters';
}
return $errors;
}
public function publish(): void {
if (!$this->validate()) {
$this->status = 'published';
$this->publishedAt = new DateTime();
}
}
}
Designing Business Objects
Single Responsibility
Each BO should have one reason to change:
// Bad: Document does too much
class Document {
public function save() { /*Database*/ }
public function sendEmail() { /*Notification*/ }
public function generatePDF() { /*Export*/ }
public function validate() { /*Rules*/ }
}
// Good: Separated concerns
class Document {
public function validate(): bool { /*Validation only*/ }
public function getData(): array { /*Data access only*/ }
}
class DocumentRepository {
public function save(Document $doc): void { /*Persistence*/ }
}
class DocumentNotifier {
public function notify(Document $doc): void { /*Email*/ }
}
class DocumentExporter {
public function toPDF(Document $doc): string { /*Export*/ }
}
Rich vs. Anemic Domain Model
Anemic Model (data containers only):
class Order {
public int $id;
public string $status;
public float $total;
public array $items;
}
// Logic in service
class OrderService {
public function calculateTotal(Order $order): float {
return array_sum(array_map(
fn($item) => $item->price * $item->quantity,
$order->items
));
}
public function canCancel(Order $order): bool {
return $order->status === 'pending';
}
}
Rich Model (encapsulated behavior):
class Order {
private int $id;
private string $status;
private array $items = [];
public function addItem(Product $product, int $quantity): void {
if ($this->status !== 'draft') {
throw new DomainException('Cannot modify submitted order');
}
$this->items[] = new OrderItem($product, $quantity);
}
public function getTotal(): float {
return array_sum(array_map(
fn($item) => $item->getSubtotal(),
$this->items
));
}
public function submit(): void {
if (empty($this->items)) {
throw new DomainException('Cannot submit empty order');
}
$this->status = 'pending';
}
public function cancel(): void {
if ($this->status !== 'pending') {
throw new DomainException('Only pending orders can be cancelled');
}
$this->status = 'cancelled';
}
}
Choose Rich Model When:
- Complex state transitions
- Invariants must be maintained
- Business rules are domain-specific
Choose Anemic Model When:
- Simple CRUD operations
- Business logic is procedural
- Team prefers transaction scripts
Value Objects
Immutable objects representing concepts without identity:
class Money {
private readonly float $amount;
private readonly string $currency;
public function __construct(float $amount, string $currency) {
if ($amount < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
$this->amount = $amount;
$this->currency = $currency;
}
public function add(Money $other): Money {
if ($this->currency !== $other->currency) {
throw new DomainException('Cannot add different currencies');
}
return new Money($this->amount + $other->amount, $this->currency);
}
public function multiply(float $factor): Money {
return new Money($this->amount * $factor, $this->currency);
}
public function equals(Money $other): bool {
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
}
class Address {
public function __construct(
public readonly string $street,
public readonly string $city,
public readonly string $postalCode,
public readonly string $country
) {}
public function format(): string {
return "{$this->street}\n{$this->city}, {$this->postalCode}\n{$this->country}";
}
}
Aggregate Roots
Group related objects and enforce boundaries:
class Order { // Aggregate root
private array $items = [];
public function addItem(Product $product, int $qty): void {
// All access to OrderItems goes through Order
$this->items[] = new OrderItem($product, $qty);
}
public function removeItem(int $itemIndex): void {
unset($this->items[$itemIndex]);
}
// OrderItem is not directly accessible
}
// Repository works with aggregate root only
interface OrderRepository {
public function save(Order $order): void; // Saves Order + Items
public function findById(int $id): ?Order; // Loads Order + Items
}
Handling Cross-Cutting Concerns
Validation
interface Validatable {
public function validate(): ValidationResult;
}
class ValidationResult {
private array $errors = [];
public function addError(string $field, string $message): void {
$this->errors[$field][] = $message;
}
public function isValid(): bool {
return empty($this->errors);
}
public function getErrors(): array {
return $this->errors;
}
}
class Document implements Validatable {
public function validate(): ValidationResult {
$result = new ValidationResult();
if (empty($this->title)) {
$result->addError('title', 'Title is required');
}
if (strlen($this->title) > 255) {
$result->addError('title', 'Title must be under 255 characters');
}
return $result;
}
}
Domain Events
interface DomainEvent {
public function occurredAt(): DateTimeImmutable;
}
class OrderSubmitted implements DomainEvent {
public function __construct(
public readonly int $orderId,
public readonly float $total,
private DateTimeImmutable $occurredAt = new DateTimeImmutable()
) {}
public function occurredAt(): DateTimeImmutable {
return $this->occurredAt;
}
}
class Order {
private array $domainEvents = [];
public function submit(): void {
$this->status = 'pending';
$this->domainEvents[] = new OrderSubmitted($this->id, $this->getTotal());
}
public function pullDomainEvents(): array {
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
}
Performance Considerations
Lazy Loading
class Order {
private ?array $items = null;
private ItemRepository $itemRepo;
public function getItems(): array {
if ($this->items === null) {
$this->items = $this->itemRepo->findByOrderId($this->id);
}
return $this->items;
}
}
Bulk Operations
// Bad: N+1 queries
foreach ($orders as $order) {
$order->save(); // One query per order
}
// Good: Batch operations
$orderRepository->saveAll($orders); // Single query
Key Takeaways
- Match complexity to needs: Don't over-engineer simple applications
- Encapsulate behavior: Rich domain models for complex business logic
- Use value objects: Immutable objects for concepts without identity
- Define aggregate boundaries: Control consistency and transactions
- Separate concerns: Repositories, services, and domain objects
- Consider performance: Lazy loading, batching, caching
Business object architecture requires balancing abstraction with practicality—choose patterns that solve your actual problems, not theoretical ones.