This article is written for programming beginners who are just starting to explore code architecture and design patterns. If you’re already an experienced developer (senior+), these ideas will likely seem obvious to you — feel free to skip or read for a nostalgic trip down memory lane of your first architectural mistakes.)
But if you’re just learning and have heard that MVC is the “standard” you “must always use,” you might find it useful to learn why that’s not the case, and what pitfalls this pattern hides. Let’s go! 🚀
💡 Heads up! Before diving into this article, please note that all examples and explanations are based on interpreting MVC with a Passive Model, where “M” (Model) represents a data provider — not the business logic layer. This approach is chosen to demonstrate core principles but doesn’t exclude other implementations.
Active Model
An Active Model isn’t just a data access layer. It’s the core hub of all business logic. Models encapsulate not only data but also behavior, and may include other models — forming a rich, expressive domain layer.
Passive Model
A Passive Model acts purely as a set of data-access functions, essentially serving as a thin wrapper around a database.
A discussion of the “M” as the business logic layer will be covered in a dedicated section at the end of the article.
Why is MVC (passive model) still alive in 2025?
Universities and courses keep teaching MVC as the “best practice,” churning out generations of developers who remain oblivious to design patterns, DRY, KISS, SOLID principles, Clean Architecture, CQRS, DDD, or Event-Driven approaches. It’s a vicious cycle: poorly trained developers build poorly designed MVC projects. But MVC only seems simple at first glance. Juniors eagerly cram all their logic into controllers, create 2000-line “god” models, and think they’re doing it right. By the time they realize the problem, the project has already become unmaintainable legacy code.
Despite its obvious flaws and the rise of far more effective architectural solutions, MVC continues to poison codebases worldwide. The reason for this tragedy lies not in the pattern’s merits, but in the skill level of the teams maintaining it. Thousands of MVC projects have turned into untouchable monsters. A single change in one controller can break half the system due to tight coupling. Companies would rather bleed out slowly, maintaining this technical debt, than face the painful but necessary decision to refactor.
On such projects, you can often hear: “If it works, better not to touch it.”
Just because poorly written code “somehow works” doesn’t mean it should stay unchanged. The worse the code, the longer and more expensive it becomes to modify. Code that “works but is messy” can fail at any moment due to hidden vulnerabilities. It’s hard to extend — new features will break old ones, it typically doesn’t scale, and it’s difficult (or practically impossible) to cover with tests.
The grim reality is this: every day we keep using MVC, we accumulate technical debt that will eventually come due. And the longer we delay adopting modern architectural solutions, the more costly the transition will be.
Main problems of MVC (passive model)
MVC (Model–View–Controller) is an architectural pattern proposed by Trygve Reenskaug in the late 1970s, which divides code into three parts: the model (data and business logic), the view (UI), and the controller (request handler). Due to its simplicity and clarity, MVC (or MVP in web development) has become the de facto standard in web development: it simplifies understanding the application and distributes responsibility for different types of work. However, its simplicity is deceptive: as a project grows, even such a pattern creates “technical debt.”
In small applications, MVC works perfectly, but as the system grows, its layers begin to blur. As the system expands, gains new features, complex business logic, and numerous integrations, the once-clear boundaries of MVC start to fade. What seemed like an ideal separation gradually turns into “fat” controllers stuffed with business logic or “bloated” models containing both domain rules and persistence details. The layers begin to mix, turning an elegant structure into a tangled mess of dependencies.
This is when we encounter a fundamental problem: MVC, originally designed for relatively simple interactive user interfaces, proves insufficiently expressive and resilient to changes in high-load, domain-driven enterprise systems. Maintaining clean layers becomes a heroic but often futile struggle, leading to reduced maintainability, increased testing complexity, and slower further development.
Using MVC alone is insufficient for building flexible and scalable applications. It becomes an obstacle to achieving a scalable and robust architecture, which is why mature applications require stricter and more specialized approaches to code organization.
❌ Performance as a Sacrifice to Architecture
MVC systems are inherently slower. Controllers drag along massive
dependencies, models execute dozens of unnecessary database queries, and
tight coupling prevents efficient component caching. Every HTTP request
initializes half the system, even when returning a simple JSON with a
single field.
❌ Scaling — A Painful Problem
When a
project grows, MVC turns into a nightmare. Individual components can’t
be scaled — you have to duplicate the entire system. Horizontal scaling
requires session synchronization, shared file systems, and other
workarounds. Microservices become impossible because everything is
tightly interconnected.
❌ Bugs as an Inevitability
Try writing a
unit test for a controller that accesses three models, sends an email,
logs to a file, and redirects the user. It’s impossible without
bootstrapping the entire system. Integration tests require a full
database, a web server, and all dependencies. The result? Projects with
5–10% test coverage, where every release is a game of Russian roulette.
Without proper testing, bugs become a constant companion in MVC projects. Changing one model breaks a controller in another part of the system. Refactoring turns into a game of “Minesweeper” — you never know what will blow up. Teams spend 60–70% of their time fixing bugs instead of developing new features.
❌ “Fat Controllers”
This is perhaps one
of the most common “diseases” of MVC in large projects. Initially,
controllers were meant to be mere mediators between models and views,
handling user input and coordinating actions. But in practice, under the
pressure of rapid iterations and the lack of a better place, business
logic starts migrating directly into controller methods.
Why is this bad?
- Violation of the
Single Responsibility Principle (SRP)
A controller should only handle HTTP requests and coordinate between models and views. But when it takes on business logic, validation, database interactions, and other responsibilities, it becomes overloaded and violates SRP. - Testing
Complexity
“Fat” controllers are hard to test in isolation due to excessive dependencies. Tests become fragile, slow, and often require mocking half the system just to verify a single method. - Poor Readability
& Maintainability
Controllers bloated with hundreds (or thousands) of lines of code turn into unreadable spaghetti. Understanding a single method requires tracing through nested conditionals, side effects, and scattered logic. - High
Coupling
A fat controller typically depends on numerous components (models, services, repositories, helpers). Changing one dependency can trigger cascading changes in the controller, making the system rigid and error-prone. - Scaling &
Evolution Challenges
- Risky modifications — Changing a fat controller is dangerous due to hidden dependencies and tangled logic.
- Slowed development — Adding features requires wading through unrelated code, increasing cognitive load, and slowing progress.
- Architectural erosion — When controllers directly handle business logic or infrastructure details (e.g., raw SQL), they blur the boundaries between Presentation, Application, Domain, and Infrastructure layers.
❌ Missing a Business
Logic Layer
Classic MVC does not provide a clear and
self-contained layer for complex, pure business logic. In MVC, the
“Model” is often treated as an ORM object or ActiveRecord, responsible
for both data and some business rules, tightly coupling them with
persistence details (database operations). Meanwhile, as mentioned
earlier, controllers become dumping grounds for everything else. This
results in the most critical business rules of the application being
“smeared” across different components — partly in models, partly in
controllers, and sometimes even directly in views.
Why is this bad?
- Blurred and
duplicated business rules
Key business rules and operations end up scattered throughout the application. This leads to constant logic duplication, as the same rule may be implemented differently in multiple places, increasing the risk of errors and inconsistencies. - Difficulty
understanding the domain
When business logic is not encapsulated in one place, it becomes extremely difficult to understand how the application’s domain actually works. This significantly slows down onboarding for new team members and complicates making changes. - No effective
testing
Business logic mixed with infrastructure details (such as HTTP requests or database operations) is nearly impossible to test in isolation with unit tests. - Reduced
reusability
Logic tied to specific frameworks, protocols (HTTP), or persistence details (SQL queries in ORM models) cannot be easily reused. - Complicated
scaling and maintenance
As the project grows, it becomes increasingly difficult to maintain, add new features, and fix bugs when there is no centralized and well-structured place for all business logic. An architecture without a domain layer tends to turn into a “Big Ball of Mud.”
❌ Reusability Issues
When business logic is tightly coupled with controllers or models bound to specific persistence technologies, reusing it becomes a major challenge.
If this logic resides in an HTTP controller or depends on web request specifics, you’ll face significant obstacles when trying to reuse it for:
- CLI commands
- Message queue consumers
- API calls
- Background processes/scheduled jobs
- Mobile/desktop clients (via GraphQL or RPC)
In each of these scenarios, the absence of a clean domain layer — decoupled from infrastructure and presentation — inevitably leads to code duplication. This increases error risks, complicates testing, and slows down further application development.
Obstacles to applying design patterns
One of the most insidious problems of MVC is how it complicates and even prevents the proper application of classical design patterns. The three-layer Model-View-Controller structure creates architectural confusion where developers get lost trying to place patterns in the right place.
The pattern placement problem. Where should the Strategy pattern be placed in MVC? In the model? But the model should work with data. In the controller? But the controller should be thin. Create a separate folder? But this violates the “purity” of MVC. The result — patterns either aren’t used at all, or are crammed into inappropriate places, losing their effectiveness.
Degradation of patterns into antipatterns. The Observer pattern in MVC often turns into a God Object — a model that notifies half the system about its changes. The Decorator pattern becomes impossible due to tight coupling of models with ActiveRecord. The Factory pattern degenerates into static methods inside models, violating OOP principles.
The impossibility of applying Enterprise patterns. The Repository pattern in MVC is usually implemented as a thin wrapper over ORM, losing its meaning. The Service Layer merges with controllers or models. Unit of Work becomes impossible due to ActiveRecord’s automatic commits. Domain Model turns into an Anemic Domain Model — models without logic, only with data.
The problem of testing patterns. Even if a pattern somehow manages to be placed in MVC, it becomes impossible to test it in isolation. The Strategy pattern, mixed with a controller, requires spinning up the entire web stack for testing. The Command pattern in a model drags along the entire ORM.
Loss of flexibility and reusability. Patterns are created to increase flexibility and code reuse. But in MVC, they lose these qualities, becoming part of a monolithic structure. You can’t extract Strategy into a separate module or reuse Command in another context.
The result of this architectural confusion is that teams either abandon design patterns (making the code primitive) or apply them incorrectly (making the code even worse). MVC doesn’t just fail to help with best practices — it actively hinders them, driving developers into an architectural dead end.
Violations of SOLID principles
SOLID principles serve as the cornerstone for creating flexible, extensible, and maintainable object-oriented code. MVC’s problems often lead to their violation:
S — Single Responsibility Principle (SRP)
Principle: A class should have only one reason to change.
Violation in MVC: A controller ends up with 6+ different reasons to change (handling requests, business logic, validation, etc.).
Benefits of
compliance:
✔ Easier to understand each class’s purpose
✔
Changes affect only one aspect of the system
✔ Simpler to write unit
tests for specific functionality
✔ Fewer merge conflicts in team
development
// ❌ Bad
class UserController {
public function register(Request $request) {
// 1. Валидация HTTP-запроса
if (empty($request->email) || !filter_var($request->email, FILTER_VALIDATE_EMAIL)) {
return response()->json(['error' => 'Invalid email'], 400);
}
// 2. Бизнес-логика
if (User::where('email', $request->email)->exists()) {
return response()->json(['error' => 'User exists'], 409);
}
// 3. Работа с базой данных
$user = User::create([
'email' => $request->email,
'password' => bcrypt($request->password)
]);
// 4. Отправка email
Mail::send('welcome', ['user' => $user], function($m) use ($user) {
$m->to($user->email)->subject('Welcome!');
});
// 5. Логирование
Log::info("User registered: {$user->email}");
// 6. Форматирование ответа
return response()->json(['user' => $user->toArray()], 201);
}
}
// ✅ Good
class UserController {
public function __construct(private UserService $userService) {}
public function register(RegisterRequest $request) {
$dto = RegisterDTO::fromRequest($request);
$user = $this->userService->register($dto);
return UserResource::make($user);
}
}
class UserService {
public function register(RegisterDTO $dto): User {
// only business logic here
}
}
O — Open/Closed Principle (OCP)
Principle: Classes should be open for extension but closed for modification.
Violation:
- Adding a new payment method requires modifying the controller (instead of extending behavior).
- New functionality forces changes to existing controllers, violating OCP.
Benefits of
compliance:
✔ New features can be added without altering
existing code (via interfaces, inheritance, or composition).
✔
Reduced risk of bugs in already-working code.
✔ More flexible and
scalable system (adaptable to new requirements).
✔ Easier backward
compatibility (core logic remains untouched).
// ❌ Bad
class PaymentController {
public function process(Request $request) {
$paymentType = $request->payment_type;
if ($paymentType === 'credit_card') {
$gateway = new CreditCardGateway();
$result = $gateway->charge($request->amount, $request->card_data);
} elseif ($paymentType === 'paypal') {
$gateway = new PayPalGateway();
$result = $gateway->pay($request->amount, $request->paypal_token);
} elseif ($paymentType === 'bitcoin') {
$gateway = new BitcoinGateway();
$result = $gateway->transfer($request->amount, $request->wallet);
}
// Для добавления нового типа платежа нужно МОДИФИЦИРОВАТЬ контроллер
return response()->json($result);
}
}
// ✅ Good
interface PaymentGatewayInterface {
public function process(PaymentData $data): PaymentResult;
}
class PaymentController {
public function __construct(
private PaymentGatewayFactory $factory
) {}
public function process(Request $request) {
$gateway = $this->factory->create($request->payment_type);
$data = PaymentData::fromRequest($request);
return $gateway->process($data); // Новые типы добавляются БЕЗ изменения контроллера
}
}
L — Liskov Substitution Principle (LSP)
Principle: Subclasses of controllers alter the behavior of the base class
Violation: They break the parent class’s contract
Benefits of compliance:
- Ensures proper polymorphism
- Safe substitution of objects with their subclasses
- Code becomes more predictable
- Easier testing with mocks and stubs
// ❌ Bad
class BaseController {
public function authenticate(Request $request): User {
$token = $request->header('Authorization');
return User::where('api_token', $token)->firstOrFail();
}
}
class AdminController extends BaseController {
public function authenticate(Request $request): User {
// Нарушение LSP: изменяет поведение родительского метода
$user = parent::authenticate($request);
if (!$user->isAdmin()) {
throw new UnauthorizedException('Admin access required');
}
return $user; // Может вернуть исключение вместо User
}
}
class GuestController extends BaseController {
public function authenticate(Request $request): User {
// Нарушение LSP: полностью меняет логику
return new GuestUser(); // Возвращает другой тип объекта
}
}
// ✅ Good
interface AuthenticatorInterface {
public function authenticate(Request $request): AuthResult;
}
class TokenAuthenticator implements AuthenticatorInterface {
public function authenticate(Request $request): AuthResult {
// Всегда возвращает AuthResult
}
}
class AdminAuthenticator implements AuthenticatorInterface {
public function authenticate(Request $request): AuthResult {
// Тоже всегда возвращает AuthResult, но с проверкой админа
}
}
I — Interface Segregation Principle (ISP)
Principle: Clients should not depend on interfaces they do not use.
Violation: A controller is forced to depend on methods it doesn’t need.
Benefits of compliance:
- Classes don’t depend on unused methods
- Interfaces become clearer and more focused
- Easier to modify parts of the system independently
- Reduces coupling between components
// ❌ Bad
class UserController {
public function __construct(private UserModel $userModel) {}
public function show($id) {
// Используем только метод find(), но зависим от ВСЕХ методов модели
return $this->userModel->find($id);
}
}
class UserModel {
public function find($id) { /* ... */ }
public function create($data) { /* ... */ }
public function update($id, $data) { /* ... */ }
public function delete($id) { /* ... */ }
public function sendEmail($id) { /* ... */ }
public function uploadAvatar($id, $file) { /* ... */ }
public function generateReport($id) { /* ... */ }
public function syncWithCRM($id) { /* ... */ }
public function calculateStatistics($id) { /* ... */ }
// Контроллер зависит от ВСЕХ этих методов, используя только один!
}
// ✅ Good
interface UserFinderInterface {
public function find(int $id): ?User;
}
interface UserCreatorInterface {
public function create(array $data): User;
}
interface UserEmailerInterface {
public function sendEmail(User $user, string $template): void;
}
class UserShowController {
public function __construct(
private UserFinderInterface $userFinder // Зависит только от нужного интерфейса
) {}
public function show($id) {
return $this->userFinder->find($id);
}
}
D — Dependency Inversion Principle (DIP)
Principle: High-level modules should not depend on low-level modules. Both should depend on abstractions.
Violation:
- Controllers directly depend on concrete implementations rather than abstractions
- Cannot switch from MySQL to PostgreSQL without modifying the controller code
- Cannot replace SMTP with another email service
- Cannot swap one API gateway for another
Benefits of
compliance:
✔ Easy to swap implementations (e.g., databases
or external APIs)
✔ Simpler unit testing with mocks
✔ More modular
system architecture
✔ Reduced dependency on specific technologies
// ❌ Bad
class OrderController {
public function create(Request $request) {
// Прямая зависимость от конкретных классов (низкий уровень)
$database = new MySQLDatabase(); // Жестко привязано к MySQL
$emailService = new SMTPEmailService(); // Жестко привязано к SMTP
$paymentGateway = new StripeGateway(); // Жестко привязано к Stripe
$logger = new FileLogger('/var/log/orders.log'); // Жестко привязано к файлу
// Высокоуровневая логика зависит от низкоуровневых деталей
$order = $database->insert('orders', $request->all());
$paymentGateway->charge($order['total'], $request->card_data);
$emailService->send($order['email'], 'Order confirmation');
$logger->log("Order created: {$order['id']}");
return response()->json($order);
}
}
// ✅ Good
interface OrderRepositoryInterface {
public function create(array $data): Order;
}
interface EmailServiceInterface {
public function send(string $to, string $template, array $data): void;
}
interface PaymentGatewayInterface {
public function charge(float $amount, array $cardData): PaymentResult;
}
class OrderController {
public function __construct(
private OrderRepositoryInterface $orderRepository,
private EmailServiceInterface $emailService,
private PaymentGatewayInterface $paymentGateway
) {}
public function create(Request $request) {
// Высокоуровневая логика зависит от абстракций
$order = $this->orderRepository->create($request->all());
$this->paymentGateway->charge($order->total, $request->card_data);
$this->emailService->send($order->email, 'order_confirmation', ['order' => $order]);
return response()->json($order);
}
}
DRY (Don’t Repeat Yourself) violations
Problems:
- Duplication of validation logic across different controllers
- Repeated data formatting logic
- Copied business rules
Solutions:
- Validation changes are made in a single place
- Easier to test the code
- Fewer errors during modifications
- Better readability and maintainability
Benefits of Compliance:
✔ Single Source of Truth — Changes are made
once and automatically applied everywhere
✔ Fewer Bugs — No risk of
forgetting to update duplicated code
✔ Faster Development — No need
to copy and adapt similar code
✔ Simpler Testing — Only one
implementation needs testing instead of multiple copies
✔ Improved
Readability — Code becomes more concise and clear
✔ Easier
Refactoring — Logic changes require edits in just one place
✔ DRY
Principle Compliance — Every piece of knowledge has a single,
unambiguous representation
✔ Reduced Maintenance Time — Less code to
analyze and debug
✔ Lower Technical Debt — No accumulation of
duplicated code
✔ Better Consistency — Uniform logic without
discrepancies
// ❌ Bad
class UserController {
public function create() {
// Дублирование валидации
if (empty($_POST['email']) || !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
return ['error' => 'Некорректный email'];
}
if (empty($_POST['name']) || strlen($_POST['name']) < 2) {
return ['error' => 'Имя должно быть минимум 2 символа'];
}
// Дублирование форматирования
$userData = [
'name' => ucfirst(trim($_POST['name'])),
'email' => strtolower(trim($_POST['email'])),
'created_at' => date('Y-m-d H:i:s')
];
// Создание пользователя
return $this->userModel->create($userData);
}
public function update($id) {
// Та же валидация - дублирование!
if (empty($_POST['email']) || !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
return ['error' => 'Некорректный email'];
}
if (empty($_POST['name']) || strlen($_POST['name']) < 2) {
return ['error' => 'Имя должно быть минимум 2 символа'];
}
// То же форматирование - дублирование!
$userData = [
'name' => ucfirst(trim($_POST['name'])),
'email' => strtolower(trim($_POST['email'])),
'updated_at' => date('Y-m-d H:i:s')
];
return $this->userModel->update($id, $userData);
}
}
class ProfileController {
public function updateProfile($userId) {
// Снова та же валидация - третье дублирование!
if (empty($_POST['email']) || !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
return ['error' => 'Некорректный email'];
}
if (empty($_POST['name']) || strlen($_POST['name']) < 2) {
return ['error' => 'Имя должно быть минимум 2 символа'];
}
// И снова то же форматирование!
$userData = [
'name' => ucfirst(trim($_POST['name'])),
'email' => strtolower(trim($_POST['email']))
];
return $this->userModel->update($userId, $userData);
}
}
// ✅ Good
// Выносим валидацию в отдельный класс
class UserValidator {
public static function validate($data) {
$errors = [];
if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Некорректный email';
}
if (empty($data['name']) || strlen($data['name']) < 2) {
$errors[] = 'Имя должно быть минимум 2 символа';
}
return $errors;
}
}
// Выносим форматирование в отдельный класс
class UserFormatter {
public static function format($data) {
return [
'name' => ucfirst(trim($data['name'])),
'email' => strtolower(trim($data['email']))
];
}
}
// Теперь контроллеры используют общую логику
class UserController {
public function create() {
$errors = UserValidator::validate($_POST);
if (!empty($errors)) {
return ['errors' => $errors];
}
$userData = UserFormatter::format($_POST);
$userData['created_at'] = date('Y-m-d H:i:s');
return $this->userModel->create($userData);
}
public function update($id) {
$errors = UserValidator::validate($_POST);
if (!empty($errors)) {
return ['errors' => $errors];
}
$userData = UserFormatter::format($_POST);
$userData['updated_at'] = date('Y-m-d H:i:s');
return $this->userModel->update($id, $userData);
}
}
class ProfileController {
public function updateProfile($userId) {
$errors = UserValidator::validate($_POST);
if (!empty($errors)) {
return ['errors' => $errors];
}
$userData = UserFormatter::format($_POST);
return $this->userModel->update($userId, $userData);
}
}
// Еще лучше - создать сервис-класс
class UserService {
public function processUserData($data) {
$errors = UserValidator::validate($data);
if (!empty($errors)) {
return ['success' => false, 'errors' => $errors];
}
return ['success' => true, 'data' => UserFormatter::format($data)];
}
}
// Теперь контроллеры становятся еще проще
class UserController {
private $userService;
public function __construct() {
$this->userService = new UserService();
}
public function create() {
$result = $this->userService->processUserData($_POST);
if (!$result['success']) {
return $result;
}
$userData = $result['data'];
$userData['created_at'] = date('Y-m-d H:i:s');
return $this->userModel->create($userData);
}
}
KISS (Keep It Simple, Stupid) Violations
Problems:
- Controllers become complex due to mixed responsibilities
- Models are overloaded with heterogeneous functionality
Solutions:
- Keep controllers simple — only accept requests and return responses
- Each class solves one simple task
- Models contain only data and basic processing logic
- Services coordinate simple actions
Benefits of Compliance:
✔ Easier Code Understanding — Simple
solutions are quicker for new developers to grasp
✔ Faster Bug
Detection — Bugs are more obvious in straightforward code
✔ Simpler
Feature Addition — No need to navigate complex architecture
✔ Reduced
Debugging Time — Simple components are easier to test and fix
✔ Lower
Cognitive Load — Developers can focus on business logic
✔ Faster
Development — Simple solutions are implemented quicker than complex ones
✔
Fewer Error Opportunities — Simpler code has fewer bug risks
✔ Easier
Maintenance — Simple systems are simpler to modify and extend
✔
Better Performance — Simple solutions often run faster
✔ Simplified
Testing — Basic functions are easier to cover with tests
✔ Lower
Development Costs — Less time spent writing and maintaining code
✔
Increased Reliability — Simple systems are more stable and predictable
// ❌ Bad
// Сложный контроллер смешивает много ответственностей
class ProductController {
public function create() {
// Валидация
if (empty($_POST['name']) || strlen($_POST['name']) < 3) {
return ['error' => 'Название должно быть минимум 3 символа'];
}
if (empty($_POST['price']) || !is_numeric($_POST['price']) || $_POST['price'] <= 0) {
return ['error' => 'Некорректная цена'];
}
// Обработка данных
$name = ucfirst(trim($_POST['name']));
$price = round(floatval($_POST['price']), 2);
$slug = strtolower(str_replace(' ', '-', $name));
// Проверка уникальности
$existing = $this->db->query("SELECT id FROM products WHERE slug = ?", [$slug]);
if ($existing) {
$slug .= '-' . time();
}
// Сохранение
$id = $this->db->insert("INSERT INTO products (name, price, slug, created_at) VALUES (?, ?, ?, NOW())",
[$name, $price, $slug]);
// Отправка уведомления администратору
mail('admin@site.com', 'Новый товар', "Создан товар: $name, цена: $price");
// Логирование
error_log("Product created: $name ($id)");
return ['success' => true, 'id' => $id];
}
}
// Перегруженная модель
class Product {
public $id, $name, $price, $slug;
// Работа с БД
public function save() {
return $this->db->insert("INSERT INTO products...");
}
// Валидация
public function isValid() {
return !empty($this->name) && $this->price > 0;
}
// Форматирование
public function formatPrice() {
return '$' . number_format($this->price, 2);
}
// Генерация slug
public function generateSlug() {
return strtolower(str_replace(' ', '-', $this->name));
}
// Отправка уведомлений
public function notifyAdmin() {
mail('admin@site.com', 'Товар изменен', "Товар {$this->name} обновлен");
}
}
// ✅ Good
// Простой контроллер с одной задачей
class ProductController {
private $productService;
public function __construct() {
$this->productService = new ProductService();
}
public function create() {
try {
$product = $this->productService->create($_POST);
return ['success' => true, 'id' => $product->id];
} catch (Exception $e) {
return ['error' => $e->getMessage()];
}
}
}
// Простой сервис координирует действия
class ProductService {
private $validator;
private $repository;
private $notifier;
public function __construct() {
$this->validator = new ProductValidator();
$this->repository = new ProductRepository();
$this->notifier = new ProductNotifier();
}
public function create($data) {
$this->validator->validate($data);
$product = new Product($data);
$product->id = $this->repository->save($product);
$this->notifier->notifyCreated($product);
return $product;
}
}
// Каждый класс решает одну простую задачу
class ProductValidator {
public function validate($data) {
if (empty($data['name']) || strlen($data['name']) < 3) {
throw new Exception('Название должно быть минимум 3 символа');
}
if (empty($data['price']) || $data['price'] <= 0) {
throw new Exception('Некорректная цена');
}
}
}
class ProductRepository {
public function save(Product $product) {
$sql = "INSERT INTO products (name, price, slug) VALUES (?, ?, ?)";
return $this->db->insert($sql, [$product->name, $product->price, $product->slug]);
}
}
class ProductNotifier {
public function notifyCreated(Product $product) {
mail('admin@site.com', 'Новый товар', "Создан: {$product->name}");
}
}
// Простая модель только с данными
class Product {
public $id, $name, $price, $slug;
public function __construct($data) {
$this->name = ucfirst(trim($data['name']));
$this->price = round(floatval($data['price']), 2);
$this->slug = $this->createSlug($this->name);
}
private function createSlug($name) {
return strtolower(str_replace(' ', '-', $name));
}
}
Violations of GRASP principles
GRASP is are fundamental principles for assigning responsibilities in object-oriented design. Pure MVC systematically violates most of them.
Information Expert
Principle: Responsibility should be assigned to the class that has the necessary information to fulfill it.
Violation: Controllers perform data operations they don’t own.
// ❌ Bad
class UserController {
public function calculateDiscount($userId) {
$user = User::find($userId);
$orders = Order::where('user_id', $userId)->get();
// Контроллер вычисляет скидку, не имея информации о бизнес-правилах
$totalSpent = $orders->sum('total');
if ($totalSpent > 10000) {
return 0.15; // 15% скидка
} elseif ($totalSpent > 5000) {
return 0.10; // 10% скидка
}
return 0;
}
}
// ✅ Good
class User {
public function calculateDiscount(): float {
$totalSpent = $this->orders->sum('total');
return $this->discountStrategy->calculate($totalSpent);
}
}
Creator
Principle: Class A should create instances of class B if A contains B, aggregates B, or has data to initialize B.
Violation: Controllers create objects without a logical reason.
// ❌ Bad
class OrderController {
public function create(Request $request) {
$order = new Order(); // Почему контроллер создает заказ?
$order->user_id = $request->user_id;
foreach ($request->items as $item) {
$orderItem = new OrderItem(); // И элементы заказа?
$orderItem->product_id = $item['product_id'];
$orderItem->quantity = $item['quantity'];
$order->items()->save($orderItem);
}
$payment = new Payment(); // И платеж?
$payment->order_id = $order->id;
$payment->save();
}
}
// ✅ Good
class Order {
public function addItem(Product $product, int $quantity): OrderItem {
// Заказ создает свои элементы - логично
return new OrderItem($this, $product, $quantity);
}
public function createPayment(PaymentMethod $method): Payment {
// Заказ создает свой платеж - логично
return new Payment($this, $method, $this->getTotal());
}
}
Controller (in the GRASP sense)
Principle: A non-UI object should handle system events and coordinate work.
Violation: HTTP controller mixed with domain controller.
// ❌ Bad
class PaymentController {
public function processPayment(Request $request) {
// HTTP-логика смешана с бизнес-логикой
$order = Order::find($request->order_id);
// Валидация HTTP-запроса
if (!$request->has('payment_method')) {
return response()->json(['error' => 'Payment method required'], 400);
}
// Бизнес-логика обработки платежа
if ($order->status !== 'pending') {
return response()->json(['error' => 'Order not pending'], 400);
}
// Вызов внешнего API
$paymentGateway = new PaymentGateway();
$result = $paymentGateway->charge($order->total, $request->payment_method);
if ($result->success) {
$order->status = 'paid';
$order->save();
// Отправка email
Mail::send('payment_success', ['order' => $order]);
}
}
}
// ✅ Good
class PaymentController { // HTTP-контроллер
public function processPayment(Request $request) {
$dto = ProcessPaymentDTO::fromRequest($request);
$result = $this->paymentService->processPayment($dto);
return PaymentResource::make($result);
}
}
class PaymentService { // Доменный контроллер
public function processPayment(ProcessPaymentDTO $dto): PaymentResult {
// Только бизнес-логика
$order = $this->orderRepository->find($dto->orderId);
$this->paymentValidator->validate($order, $dto);
return $this->paymentProcessor->process($order, $dto->paymentMethod);
}
}
Low Coupling
Principle: Classes should have minimal dependencies on each other.
Violation: Controllers are tightly coupled to models, external services, and views.
// ❌ Bad
class UserController {
public function updateProfile(Request $request) {
$user = User::find($request->user_id); // Связь с Eloquent
// Связь с валидационной логикой
if (!filter_var($request->email, FILTER_VALIDATE_EMAIL)) {
return redirect()->back()->withErrors(['email' => 'Invalid email']);
}
// Связь с файловой системой
if ($request->hasFile('avatar')) {
$path = $request->file('avatar')->store('avatars', 's3');
$user->avatar = $path;
}
// Связь с внешним API
$geocoder = new GoogleGeocoder();
$coordinates = $geocoder->geocode($request->address);
// Связь с email-сервисом
Mail::to($user->email)->send(new ProfileUpdatedMail($user));
// Связь с кешем
Cache::forget("user.{$user->id}");
return view('profile.updated', compact('user')); // Связь с представлением
}
}
// ✅ Good
class UserController {
public function __construct(
private UserServiceInterface $userService
) {}
public function updateProfile(UpdateProfileRequest $request) {
$dto = UpdateProfileDTO::fromRequest($request);
$user = $this->userService->updateProfile($dto);
return UserResource::make($user);
}
}
High Cohesion
Principle: Elements of a class should work together to achieve a common goal.
Violation: Controllers contain disparate functionality.
// ❌ Bad
class UserController {
public function register(Request $request) { /* регистрация */ }
public function login(Request $request) { /* аутентификация */ }
public function updateProfile(Request $request) { /* обновление профиля */ }
public function uploadAvatar(Request $request) { /* загрузка файлов */ }
public function sendEmail(Request $request) { /* отправка email */ }
public function generateReport(Request $request) { /* генерация отчетов */ }
public function exportData(Request $request) { /* экспорт данных */ }
public function calculateStatistics() { /* расчет статистики */ }
// Один контроллер делает все!
}
// ✅ Good
class UserRegistrationController {
public function register(Request $request) { /* только регистрация */ }
}
class UserAuthenticationController {
public function login(Request $request) { /* только аутентификация */ }
}
class UserProfileController {
public function show(Request $request) { /* показ профиля */ }
public function update(Request $request) { /* обновление профиля */ }
}
Polymorphism
Principle: Use polymorphic operations instead of type conditions.
Violation: Controllers contain multiple conditions instead of polymorphism.
// ❌ Bad
class PaymentController {
public function process(Request $request) {
$paymentType = $request->payment_type;
if ($paymentType === 'credit_card') {
$gateway = new CreditCardGateway();
$result = $gateway->charge($request->amount, $request->card_data);
} elseif ($paymentType === 'paypal') {
$gateway = new PayPalGateway();
$result = $gateway->pay($request->amount, $request->paypal_token);
} elseif ($paymentType === 'crypto') {
$gateway = new CryptoGateway();
$result = $gateway->transfer($request->amount, $request->wallet_address);
}
// При добавлении нового типа нужно модифицировать контроллер
}
}
// ✅ Good
interface PaymentGatewayInterface {
public function process(PaymentData $data): PaymentResult;
}
class PaymentController {
public function process(Request $request) {
$gateway = $this->paymentGatewayFactory->create($request->payment_type);
$data = PaymentData::fromRequest($request);
return $gateway->process($data); // Полиморфный вызов
}
}
Pure Fabrication
Principle: Create artificial classes to achieve low coupling and high cohesion.
Violation: No middleware leads to direct coupling.
// ❌ Bad
class UserController {
public function register(Request $request) {
// Прямая работа с базой данных
$user = new User();
$user->email = $request->email;
$user->save();
// Прямая работа с внешним API
$emailService = new SendGridEmailService();
$emailService->sendWelcomeEmail($user->email);
// Прямая работа с файловой системой
Storage::disk('s3')->put("users/{$user->id}/avatar", $request->file('avatar'));
}
}
// ✅ Good
class UserRegistrationService { // Искусственный класс для координации
public function __construct(
private UserRepositoryInterface $userRepository,
private EmailServiceInterface $emailService,
private FileStorageInterface $fileStorage
) {}
public function register(RegisterUserDTO $dto): User {
$user = $this->userRepository->create($dto);
$this->emailService->sendWelcomeEmail($user);
if ($dto->avatar) {
$this->fileStorage->storeAvatar($user, $dto->avatar);
}
return $user;
}
}
Indirection
Principle: Avoid direct communication between components by using intermediate objects.
Violation: Controllers directly access models and external services.
// ❌ Bad
class OrderController {
public function create(Request $request) {
$order = Order::create($request->all()); // Прямая связь с моделью
// Прямая связь с внешним сервисом
$inventory = new InventoryService();
$inventory->reserveItems($order->items);
// Прямая связь с платежным шлюзом
$paymentGateway = new StripeGateway();
$paymentGateway->createPaymentIntent($order->total);
}
}
// ✅ Good
class OrderController {
public function __construct(
private OrderServiceInterface $orderService // Непрямая связь
) {}
public function create(CreateOrderRequest $request) {
$dto = CreateOrderDTO::fromRequest($request);
return $this->orderService->createOrder($dto); // Делегирование
}
}
What does the community say?
Martin Fowler’s Perspective on MVC
“Parts of classic MVC don’t really make sense for rich clients these days.” → He argues that classic MVC is outdated and unsuitable for modern client applications.
How MVC Frameworks Taught Us Bad Habits — Part 2: How Monoliths are Born
Project changes lead to bloated controllers and models. The business layer becomes “giant”, absorbing most of the application logic.
A Journey Through Anti-Patterns and Code Smells
“Write business logic in view templates” → Business logic in views harms readability.
“Set view variables everywhere” → Controllers grow, and data assignments scatter chaotically, hurting maintainability and testability.
MVC Is Dead
“Now I’m not convinced that MVC was ever a great idea for full-stack apps.” → Almost everything happens in controllers (mvC), while models and views are minimized, reducing MVC to just “C.”
MVC is Evil, Nowadays
“Scalability is not feasible when large percentages of your additions constantly require modifications to your existing system.” → MVC scales poorly because new features always require modifying existing components, violating responsibility segregation.
MVC/MVP/MVVM? No Thanks
“MVC: Ideal for small-scale projects only.” → MV* patterns struggle in large systems — they don’t scale well and demand migration to more complex alternatives.
Argument — It’s Time to Stop Making MVC Applications
- Controllers increasingly serve as business logic injection points rather than UI handlers, turning into “MVC Video Streaming” or “MVC Warehouse Management” apps.
- Business logic becomes tightly coupled with controllers, defeating MVC’s original purpose.
MVC — a Problem or a Solution?
“Then, someone noted that ‘thin controller’ is not always the best approach. They thus created the rule of fat controller, thin model… we made poor MVC give birth to HMVC, MVA, MVP, MVVM…” → The shift to fat controllers (and anemic models) led to convoluted code, spawning fragmented derivatives like MVVM, MVP, and HMVC.
(02.07.25) Rethinking the “Model” in MVC as an Active Model
The main confusion arises because frameworks like Laravel or CodeIgniter commonly understand the “Model” as a specific implementation of the Active Record pattern. This is convenient for rapid development, but it distorts the original concept.
This leads to the same confusion as with “Facades” in Laravel. The framework calls a mechanism a “Facade” that is not an implementation of the classic Facade design pattern. As a result, many developers criticize the pattern itself based on its specific and distorted implementation.
For comparison, in the Symfony ecosystem, no one calls an ORM entity (Entity) a “Model”. There, these concepts are more clearly separated, with distinct boundaries between the domain model and the database model.
Thus, the problems often attributed to MVC are actually problems of its specific, simplified implementations. But if we look at the model as a domain model, it is not just a set of classes reflecting database tables. It is a living, breathing system of abstractions that describes the knowledge, rules, and processes of a specific domain. It already includes Value Objects, Entities (Model), Aggregates, Services, Repositories, Events…
Conclusion
Using MVC as a passive model is a convenient starting point for small and medium projects in its classical understanding. It’s simple, widely understood, and supported by most frameworks. However, as the product grows, MVC becomes an architectural bottleneck: it violates modularity principles, complicates testing, and leads to tight coupling between components.
Use MVC as an active model 😉.
P.S. We would be glad if you shared your experiences and advice in the comments.
That’s it 🎉, thanks for reading, don’t forget to follow me 😊 and clap 👏 the article.