MVC Without View Pattern
As I've been diving deeper into microservice architecture with TypeScript, I've found myself implementing a pattern that I like to call "MVC without View" - essentially an adaptation of the traditional Model-View-Controller pattern for backend services. Today I want to share my personal experiences with this approach, along with the pros and cons I've encountered along the way.
What is MVC Without View?
The traditional MVC (Model-View-Controller) pattern separates an application into three interconnected components:
Model: Data and business logic
View: User interface elements
Controller: Handles requests and orchestrates responses
In backend microservices, particularly TypeScript-based ones, we often implement what is essentially "MVC without View" - since the presentation layer is typically handled by separate frontend applications or API consumers.
This pattern typically looks like:
Model: Data structures, database interactions
Controller: API endpoints that handle HTTP requests/responses
Service Layer: (Instead of View) Contains business logic
Sequence Diagram of MVC Without View in Action
To better understand how this pattern works in practice, here's a sequence diagram showing a typical request flow through a TypeScript microservice:
This diagram illustrates how a request flows through the different layers, with each having clear responsibilities.
My Implementation in TypeScript Microservices
In my TypeScript microservices, I typically structure projects like this:
/src
/controllers
userController.ts
/models
userModel.ts
/services
userService.ts
/repositories
userRepository.ts
/dtos
userDto.ts
app.ts
Here's a concrete example from a user management microservice I built:
// models/user.model.ts
export interface User {
id: string;
username: string;
email: string;
createdAt: Date;
}
// repositories/user.repository.ts
export class UserRepository {
async findById(id: string): Promise<User | null> {
// Database interaction to find user
return await db.users.findUnique({ where: { id } });
}
async create(userData: Omit<User, 'id' | 'createdAt'>): Promise<User> {
// Logic to create a new user
return await db.users.create({
data: {
...userData,
id: generateUuid(),
createdAt: new Date()
}
});
}
}
// services/user.service.ts
export class UserService {
constructor(private userRepository: UserRepository) {}
async getUserById(id: string): Promise<User | null> {
return this.userRepository.findById(id);
}
async createUser(userData: CreateUserDto): Promise<User> {
// Business logic before creating user
// e.g., validation, password hashing, etc.
return this.userRepository.create(userData);
}
}
// controllers/user.controller.ts
export class UserController {
constructor(private userService: UserService) {}
async getUser(req: Request, res: Response): Promise<void> {
try {
const userId = req.params.id;
const user = await this.userService.getUserById(userId);
if (!user) {
res.status(404).json({ message: 'User not found' });
return;
}
res.status(200).json(user);
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
}
async createUser(req: Request, res: Response): Promise<void> {
try {
const userData = req.body;
const newUser = await this.userService.createUser(userData);
res.status(201).json(newUser);
} catch (error) {
res.status(400).json({ message: error.message });
}
}
}
Pros of MVC Without View in Microservices
Through my experiences, I've found several advantages to this approach:
Clear Separation of Concerns: Each component has a distinct responsibility, making the codebase more maintainable.
In my projects, I can quickly locate where specific logic resides - data access in repositories, business rules in services, and request handling in controllers.
Testability: With clear boundaries between components, unit testing becomes much simpler.
I've found that mocking dependencies (like repositories in service tests) is straightforward, allowing me to test business logic in isolation.
Flexibility: The pattern adapts well as microservices evolve or get decomposed further.
Contract-First Design: The controller layer provides a clear API contract, making it easier to document and version your APIs.
I've used this to generate OpenAPI documentation automatically from my controller definitions.
Reusability: Services and repositories can be reused across different controllers or even different microservices if properly packaged.
Cons and Challenges
However, I've also faced some challenges with this approach:
Potential Overengineering: For very simple microservices, this pattern can feel like overkill.
I've sometimes found myself creating unnecessary abstractions for CRUD operations that could be more directly implemented.
DTO Transformation Overhead: Converting between domain models and DTOs across layers can create extra boilerplate.
In TypeScript specifically, I often end up with multiple interface definitions for what is essentially the same entity.
Service Bloat: Without careful attention, service layers can accumulate too much responsibility and become difficult to maintain.
I've had to refactor services that grew to thousands of lines as business logic expanded.
Interdependence Complexity: As the number of services grows within a microservice, managing their dependencies becomes more complex.
Learning Curve: New team members sometimes struggle with understanding where specific functionality should live.
"Should this validation be in the controller or the service?" is a common question I hear from junior developers.
Real-World Use Cases From My Experience
Let me share a few specific real-world scenarios where I've applied this pattern with great success:
1. Payment Processing Microservice
In a payment gateway integration project, I implemented an MVC Without View structure where:
Controllers: Handled payment requests/webhooks with proper validation
Services: Contained complex payment orchestration, retry logic, and integration with third-party payment processors
Models: Represented payment entities, transactions, and reconciliation data
Repositories: Managed transaction history and payment state persistence
This pattern allowed us to swap payment providers without changing our controllers or business logic. We could also implement complex payment flows like split payments or subscription billing by adding new services while keeping the API contract stable.
// Example of payment service with complex business logic
export class PaymentService {
constructor(
private paymentRepository: PaymentRepository,
private paymentGatewayFactory: PaymentGatewayFactory
) {}
async processPayment(paymentRequest: PaymentRequestDto): Promise<PaymentResult> {
// Validate payment amount against business rules
await this.validatePaymentAmount(paymentRequest);
// Select appropriate payment gateway based on payment method, region, etc.
const gateway = this.paymentGatewayFactory.getGateway(paymentRequest.method);
// Create transaction record in pending state
const transaction = await this.paymentRepository.createTransaction({
amount: paymentRequest.amount,
currency: paymentRequest.currency,
status: 'PENDING'
});
try {
// Process with selected gateway
const gatewayResult = await gateway.processPayment(paymentRequest);
// Update transaction with result
return await this.paymentRepository.updateTransaction(
transaction.id,
{
status: gatewayResult.success ? 'COMPLETED' : 'FAILED',
gatewayReference: gatewayResult.referenceId
}
);
} catch (error) {
// Handle failure, retry logic, etc.
await this.paymentRepository.updateTransaction(transaction.id, { status: 'FAILED' });
throw new PaymentProcessingError(error.message);
}
}
}
2. User Authentication and Authorization Service
For a multi-tenant SaaS application, I built an authentication microservice where:
Controllers: Exposed endpoints for login, registration, token refresh, etc.
Services: Handled JWT generation, password validation, and multi-factor authentication logic
Models: Represented users, roles, permissions, and tenants
Repositories: Managed user data storage and retrieval with proper security measures
The clear separation allowed us to implement complex security requirements like role-based access control (RBAC), tenant isolation, and audit logging while keeping the code maintainable.
3. E-Commerce Product Catalog Service
In an e-commerce platform, the product catalog microservice used this pattern to:
Controllers: Handle product CRUD operations, search, and filtering
Services: Implement complex catalog logic including categorization, pricing rules, and inventory checks
Models: Represent products, categories, attributes, and inventory data
Repositories: Manage efficient storage and retrieval of product data, often with caching strategies
This structure allowed the catalog service to efficiently handle complex queries and integrate with search engines like Elasticsearch while maintaining a clean architecture.
// Example of a product search service with complex filtering
export class ProductSearchService {
constructor(
private productRepository: ProductRepository,
private categoryService: CategoryService,
private inventoryService: InventoryService,
private searchIndexService: SearchIndexService
) {}
async searchProducts(searchParams: ProductSearchParams): Promise<SearchResult<Product>> {
// Determine search strategy based on query complexity
if (this.isSimpleQuery(searchParams)) {
// Use direct database query for simple searches
return await this.productRepository.findProducts(searchParams);
} else {
// Use search engine for complex queries
const searchResults = await this.searchIndexService.search(searchParams);
// Enrich search results with real-time inventory data
const productsWithInventory = await this.enrichWithInventoryData(searchResults.items);
// Apply business rules like visibility, regional availability
const filteredProducts = this.applyBusinessRules(productsWithInventory, searchParams);
return {
items: filteredProducts,
total: searchResults.total,
page: searchParams.page,
pageSize: searchParams.pageSize
};
}
}
}
My Personal Best Practices
Over time, I've developed some best practices that help me get the most out of this pattern:
Keep controllers thin: Controllers should only validate requests, call appropriate services, and format responses - no business logic.
Use dependency injection: This makes testing easier and enforces clean dependencies between layers.
Create clear interfaces: Define interfaces for each service and repository to make dependencies explicit.
Use middleware for cross-cutting concerns: Authentication, logging, error handling can be implemented as middleware rather than repeating in controllers.
Balance pragmatism with purism: Sometimes it's okay to bend the pattern a bit for simplicity's sake, particularly in very small microservices.
Conclusion
The "MVC without View" pattern has served me well in TypeScript microservice development. It provides structure without being overly prescriptive, and scales well as applications grow in complexity.
I've found this pattern particularly valuable in different industries:
FinTech: Where separation of concerns is critical for security and compliance
Healthcare: Where complex business rules and regulatory requirements demand clear organization
E-commerce: Where scalability and flexibility are essential for handling variable traffic
And at different scales:
Startups: Provides a clear structure when building an MVP that can scale
Enterprise: Facilitates collaboration across large teams with well-defined boundaries
However, it's not a silver bullet, and like any pattern, it should be applied thoughtfully based on the specific needs of your project. As with most things in software development, the key is finding the right balance between structure and flexibility.
One final thought - I've learned that over-optimizing architecture early can be as problematic as having no architecture at all. The beauty of "MVC without View" is that it provides just enough structure to be clear without being overly rigid, allowing your services to evolve naturally with your business needs.
What patterns have you found effective in your microservice development? I'd love to hear about your experiences!
Last updated