OOP doesn't exist without class encapsulation
First, answer me this question: which example (1 or 2) uses OOP?
// example #1 class Email { public id: string public value: string public isActivated: boolean public main: boolean } class User { public id: string public password: string public emails: Email[] } class UserService { registerNewUser(password: string, email: string) { const mainEmail = new Email( new UUID(), email, false, true, ) const user = new User( new UUID(), hashPassword(password), [mainEmail] ) return user } changeUserEmail(user: User, newEmail: string) { const oldMainEmail = user.emails.find(email => email.main) oldMainEmail.main = false const newMainEmail = new Email( new UUID(), newEmail, false, true, ) user.emails.push(newMainEmail) } } // example #2 function Email ( id: string, value: string, isActivated: boolean, main: boolean ) { const state = { id, value, isActivated, main } return { makeMain() { state.main = true }, unmain() { state.main = false }, isMain() { return state.main } } } function User = ( id: string, password: string, emails: Email[], ) { const state = { id, password, emails, } const getMainEmail = () => { return state.emails.find(email => email.isMain()) } return { changeUserEmail(email: string) { const oldMainEmail = getMainEmail() oldMainEmail.unmain() const newMainEmail = Email( new UUID(), newEmail, false, false, ) newMainEmail.main() user.emails.push(newMainEmail) } } } function registerNewUser = (password: string, email: string): User => { const mainEmail = Email( new UUID(), newEmail, false, false, ) mainEmail.main() const user = User( new UUID(), hashPassword(password), [mainEmail] ) return user }
Though I've simplified some details here, I can confidently say that if you answered "1" - I have 2 pieces of news for you, good and bad:
- Bad - you don't understand what OOP is
- Good - perhaps all this time you've been using something similar to FOP
This code does use "OOP syntax," but it doesn't conform to the "OOP methodology" that we discussed in the previous chapter.
Canonical OOP Methodology
The main idea of canonical OOP:
"A program consists of 'objects' that encapsulate data and behavior and communicate through messages"
The problem with example #1 is that the "objects"
User
and Email
absolutely don't encapsulate anything, neither data nor behavior. They are simply carriers of public data, regular POJO / DTO / Record / Struct, while behavior exists completely separately in ...Service
.If we talk about canonical OOP, each of these "objects" should hide data and only expose permitted behavior:
class User { // First, we hide all properties because // public properties in OOP are a bad practice private id: string private password: string private emails: Email[] // Again, I'm not writing out all the logic, but it can be intuitively understood private getMainEmail() { // ... } static registerNewUser(password: string, email: string): User { // ... } changeUserEmail(user: User, newEmail: string) { // ... } } class Email { private id: string private value: string private isActivated: boolean private isMain: boolean makeMain() { // ... } unmain() { // ... } }
Now each object encapsulates all data and behavior related to it. At the same time, to allow "objects to communicate," we create a graph of relationships through which "objects" can call each other's methods, passing "messages" as arguments.
This approach is also called Fat / Rich Model and it is the canonical approach for OOP.
Here we can understand that example #2, although it doesn't use OOP syntax, in essence corresponds to our idea of canonical OOP because it encapsulates data and behavior and has connections between objects (reread example #2 once more).
This is the very example that shows that using "OOP syntax" ≠ "OOP methodology".
But why don't we do it this way?
The main reasons are 2:
We didn't know that's how it should be done
You can see a huge amount of code on the internet that claims to be OOP, but ubiquitously allows public class properties and operations on them outside of the class.
But just think about it: we've been saying all along that the strongest aspect of OOP is encapsulation, and yet we make our properties public and even allow them to be modified outside the class? Is that encapsulation?
We begin to learn and pass knowledge to the next developers based on others' work, which at the time didn't fully understand the issue and created distortion that gets passed along like a game of "telephone".
Cross Cutting Concern
The problem that absolutely every developer faces on absolutely every project: what if we can't choose which "object" to start writing logic for because it equally affects several "objects" at once?
For example, we have a Courier, a Newspaper, and a Customer. We need to write logic for "buying-selling a newspaper." Should we create a
sellPaper
function for the Courier or a buyPaper
function for the Customer?Most will decide to create a separate
SellPaperService
class and pass the Courier and Customer to it, operating on their properties/methods. But SellPaperService
is not an "object"; it has no state, it's just a function written in OOP syntax that operates on "objects," which shouldn't exist in OOP architecture.Is it possible to follow canonical OOP?
Yes. To do this, you'll have to follow SOLID, use all OOP patterns (meaning add a ton of abstractions), and, ideally, use Domain Driven Design (DDD).
If the infamous SOLID and patterns are more well-known, DDD remains a mystery to many, and since this book is not about DDD, I'll superficially list the rules you need to follow in it:
- Instead of Model, the concept of Aggregate is used, which is a stricter Model
- An Aggregate consists of Entities that have IDs, references to other Entities, and immutable Value Objects
- The topmost Entity in an Aggregate, containing other Entities and Value Objects, is called the Root Entity
- All application logic is in Aggregates, which operate on Entities and Value Objects
- Aggregates can reference each other, but only by ID
- Aggregates have no right to reference each other's Entities and Value Objects, only the Root Entity
- 1 business process (consider it an API endpoint) === 1 Aggregate method
- 1 Aggregate method === 1 transaction
- You must retrieve the entire Aggregate from the database before performing logic and save it entirely at the end of the logic
And this is just the tip of the iceberg, because for all this to work, Clean Architecture is thrown in as well. If you want to dive into DDD (which I don't recommend), you can read The Red Book by Vaughn Vernon, and then follow up with The Blue Book by Eric Evans.
All this leads us to the fact that the entire program is divided into Aggregates, which contain a lot of objects (Entity / Value Object), and due to the tree-like structure of Aggregates and reference rules, the graph of our application remains quite clean, and for one business process, it's enough to run one method of a specific Aggregate because it will contain all the necessary data.
And to achieve this, we'll need to spend months studying DDD, introduce an incredible number of abstractions into the code, spend hours designing suitable Aggregates, and twice as much time refactoring when new features require new connections between existing Aggregates, all in the name of maintaining canonical OOP.
"So what have I been using all this time?"
Okay, if canonical OOP means smart "objects" with all private properties and all business processes starting with a method call of one of them, then what have I and a bunch of other developers been using all this time?
I'll assume you've been doing something like this:
You wrote classes for your
Models
, made at least half of the properties public (if not all) + added some business logic methods to them, but as soon as something became inconvenient to describe in the model itself, you created some …Service
where you put the remaining logic.Something like this:
class User { public id: string public password: string public emails: Email[] getMainEmail() { // ... } changeEmail(user: User, newEmail: string) { // ... } } class Email { public id: string public value: string public isActivated: boolean public main: boolean } class UserService { registerNewUser(password: string, email: string) { // ... } changeUserEmail(user: User, newEmail: string) { // ... } deleteUser(user: User) { // ... } }
Interestingly, in the OOP world, the pattern of moving some logic outside even has a name: Slim / Anemic Model, which is actually closer to procedural programming than to OOP.
This name appeared because programmers too often encounter cases where it's inconvenient to add behavior to a model, so they have to create
…Service
classes, make properties public, and operate on models through them.So what's the problem?
It turns out that we have only 3 paths:
- Use canonical OOP
- Use a hybrid of OOP with some functional or procedural programming
- Don't use OOP at all
In the first case, you'll get incredibly rapidly growing "artificial complexity" (accidental complexity) of code, meaning the program becomes complicated by developer-invented abstractions that may have already outlived their usefulness (even the core pillars of OOP have long been subject to criticism).
And instead of solving problems, you'll spend hours trying to understand what each of these abstractions is responsible for and what new one you need to create to adhere to OOP.
In the second case, you'll lose the advantages of OOP because any other paradigm requires breaking encapsulation, but you'll get all the disadvantages and most of the abstractions of OOP, turning the code into a mess (google OOP Anemic Model and you'll understand why it's called an anti-pattern in the context of OOP).
And the third... the third is the trickiest option... but I won't get ahead of myself and will give you the opportunity to continue reading the following chapters, where we'll be drawing conclusions.
What's Next
Okay, let's assume I haven't convinced you and you still believe that you've been using perfectly canonical OOP all this time.
I at least wanted to highlight to you that your approach has a name (Slim / Anemic Model) and that with it, you're moving away from the tenets of OOP (at least full encapsulation).
In the next chapter, I want to tell you about problems you'll face, regardless of which style of OOP you use, and the only solution to which will be to stop using OOP as such:
Useful Links
👈 Previous Chapter
Next Chapter 👉