Skip to content
Davor Minchorov

Building Maintainable PHP Applications: Over-engineering vs under-engineering

This article is part of the Building Maintainable PHP Applications series

The official definition of over-engineering from Wikipedia:

Overengineering (or over-engineering) is the act of designing a product or providing a solution to a problem in an elaborate or complicated manner, where a simpler solution can be demonstrated to exist with the same efficiency and effectiveness as that of the original design.

How I understand Over-engineering and under-engineering in terms of code:

Over-engineering is usually a word that is thrown around for code that is either:

  • great and makes sense for maintainability for the long run

  • badly written and completely unnecessary

Here’s an example of code that people may think that is over-engineered:

1<?php
2 
3declare(strict_types=1);
4 
5namespace App\Application\Members;
6 
7use App\Application\EventDispatcher\EventDispatcher;
8use App\Domain\Members\EmailAddress;
9use App\Domain\Members\EmailAddressIsAlreadyTaken;
10use App\Domain\Members\FirstName;
11use App\Domain\Members\Id;
12use App\Domain\Members\LastName;
13use App\Domain\Members\Member;
14use App\Domain\Members\MemberRepository;
15use App\Domain\Members\Password;
16use App\Domain\Members\StatusName;
17use Assert\AssertionFailedException;
18use Illuminate\Contracts\Hashing\Hasher;
19use Symfony\Component\Clock\ClockInterface;
20 
21final readonly class SignUpMemberCommandHandler
22{
23 public function __construct(
24 private MemberRepository $memberRepository,
25 private EventDispatcher $eventDispatcher,
26 private ClockInterface $clock,
27 private Hasher $hasher
28 ) {
29 }
30 
31 /**
32 * @throws EmailAddressIsAlreadyTaken|AssertionFailedException
33 */
34 public function handle(SignUpMember $signUpMember): void
35 {
36 $emailAddress = EmailAddress::createFromString(value: $signUpMember->emailAddress);
37 
38 $emailAddressExists = $this->memberRepository->existsByEmailAddress(emailAddress: $emailAddress->getValue());
39 
40 if ($emailAddressExists) {
41 throw new EmailAddressIsAlreadyTaken();
42 }
43 
44 $member = Member::signUp(
45 id: Id::createFromString(value: $this->memberRepository->generateIdentity()),
46 firstName: FirstName::createFromString(value: $signUpMember->firstName),
47 lastName: LastName::createFromString(value: $signUpMember->lastName),
48 emailAddress: $emailAddress,
49 status: StatusName::PENDING,
50 createdAt: $this->clock->now(),
51 updatedAt: $this->clock->now(),
52 password: Password::createFromString(value: $this->hasher->make(value: $signUpMember->password))
53 );
54 
55 $this->memberRepository->save(member: $member);
56 
57 $this->eventDispatcher->dispatchMultiple(events: $member->releaseEvents());
58 }
59}

This code looks like it’s over-engineered at first when you see it, why would anyone write code like this for such a simple create operation? It must be the ego, right?

I don’t believe that people go to work thinking “I am going to write this complex code just so that I can make my life harder, because building software is easy”.

However, I do believe that when people who understand what they are doing, write code like this so that they can make their life easier in order to manage the complexity.

I do understand that there are people who misuse and misunderstand design patterns and principles just for the sake of it but that’s not the main reason why someone should write code like this.

The opposite can be said for under-engineering, which is usually either:

  • badly written and completely unmaintainable

  • great and makes sense for the short run

Here’s an example of code that you may think it’s fine but it’s actually under-engineered:

1<?php
2 
3class MemberController extends Controller
4{
5 public function store(StoreMemberRequest $request): MemberResource
6 {
7 $member = Member::create([
8 'first_name' => $request->first_name,
9 'last_name' => $request->last_name,
10 'email_address' => $request->email_address,
11 ]);
12 
13 return new MemberResource($member);
14 }
15}

So what determines which code is over-engineered or under-engineered?

  • The context which it’s written in

  • The level of maintainability and the ability to change it easily when the business rules and processes become more complex over time.

The first code example written in an a scale up or an enterprise software is very simple and easy to maintain.

The second code example written in a brand website or a personal website throwaway project is very simple and easy to maintain.

If the two code examples switch their contexts, they will become over-engineered and under-engineered respectively.

Usage of various design patterns and principles is also considered over-engineering, but that’s mostly because of lack of experience and/or understanding of the code.

Remember, the context is very important in order to determine if the code is over-engineered or under-engineered.

These are just simple code examples to illustrate the idea, we’ll get back to this topic at some point when we look at a more complex use case where a more complex code sample will be refactored.

Consistency is very important here, if you have some kind of structure and coding standards, it may seem like the simpler use cases of the application are over-engineered.

Your first instinct would be simplify the first example but that would remove the benefits of the code being flexible enough to change once more and more features are added in the future.

A few things that might be added to the Sign Up Member use case would be:

  • Sign up the member to the onboarding and marketing newsletters if the member gave consent for that

  • Assign a role and permissions to the member

  • Send an account activation email

  • Add an audit log entry

  • Add a tracking log where the member signed up from

  • Start the free trial

  • Apply a coupon code if applicable

  • Check if the user is above 18 years of age

  • Validate their address or location

  • Assign the member to a team

I can think of many other things that may happen in this use case and you can see how things can get complex very quickly.

If you continue writing the code in the second example right there in the controller and your context is a well-funded startup, a scale-up or an enterprise product, you will have a hard time managing that codebase.

The potential for bugs increases and the speed for developing or working with that code slows down quickly.

Once you have these features implemented, changing the whole use case to be slightly different affects many scenarios that will have to be re-tested.

On the other hand, if you are using the first example, most of the code will stay the same, and the new features will be added in new classes, which means the main class will not require many changes. This will also give you the benefit of the code readability for other team members (assuming they went through the onboarding process and they have experience writing and reading code like the first example).

If you are working alone and you are the person who comes up with these ideas, you can probably manage the code somehow, it won’t be easy but you will know what and where to change since you wrote the code, although it may not be the easiest thing to work with.