When you write code you want it to be clean and maintainable. I bet you don't want other people being puzzled while reading your code. Therefore you should follow the best practices. S.O.L.I.D are some of them.
If you are not familiar S.O.L.I.D are five principles that help to keep you code in better shape. They were introduced many years ago by Robert C. Martin. I'm writing this article as some kind of educational records. Probably you also can find them useful.
It can be pretty handy to put everything in one basket. Having one function to handle everything at once of a class, that has all the methods you ever need. But that only for the beginning of work. As soon as you start extending thing, adding new functionality everything will come out of control.
Thing will get trickier when you start extending of changing your functionality. Everything is intertwined already, so you'll need a lot of energy and time to make changes. And will you be sure everything is still working as it was before?
Keeping files and methods as small as possible can help avoid this problem. Of course it is not the only thing to do. Keep in mind - your function/class should have one reason for being changed.
// book is not expected to be able to save itself
class Book {
constructor() {}
save(): void {}
}
class Book {
constructor() {}
}
interface IRepository<T> {
save(entity: T): void;
}
class BookRepository implements IRepository<Book> {
save(book: Book): void {}
}
Mostly this principle is violated when developers are trying to implement different level functions in single class, like computing and saving data in the same method. It may be worse when there are functions doing absolutely unrelated things. These are common scenarios when you should probably move code to another component: data storing, validation, notifications, error handling, logging, formatting, parsing, data mapping.
It may be difficult to accept, but it is not welcomed to make changes in existing code. That means it is "closed". Instead you can add code when you want to change things. In this case changes won't destabilize your code. And you can avoid making lots of changes due to methods signatures changes.
How to achieve that? You can use interfaces. By simply creating new instances of this interface you can provide new behaviours without worrying about stability. But don't forget about the SRP: separate logic to different classes. Take a look at Strategy and Template Method patterns, as they are useful for following this principle.
class Rectangle {
constructor(width: number, height: number) {}
}
class Square {
constructor(height: number) {}
}
class AreaCalculator {
constructor(shapes: any[]) {}
sum() {
return this.shapes.reduce((acc, shape) => {
if (shape instanceof Square) {
acc += Math.pow(shape.height, 2);
} else if (shape instanceof Rectangle) {
acc += shape.height * shape.width;
}
return acc;
}, 0);
}
}
interface Shape {
area: () => number;
}
class Rectangle implements Shape {
constructor(width: number, height: number) {}
area() {
return this.height * this.width;
}
}
class Square implements Shape {
constructor(height: number) {}
area() {
return Math.pow(this.height, 2);
}
}
class AreaCalculator {
constructor(shapes: Shape[]) {}
sum(): number {
return this.shapes.reduce((acc, shape) => acc += shape.area(), 0);
}
}
Anyway, you cannot completely close your classes and methods from changes as there will always be at least one reason to change your code. But the goal here is to build an architecture that will allow you to minimize these changes.
In short this means derived classes should not change the logic too much therefore replacing a type with its subtype won't break your program. And this is not as simple as it looks like. Look at the examples:
class Rectangle {
width: number;
height: number;
getArea(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
get width() {
return this.width;
}
set width(value: number) {
this.width = value;
this.height = value;
}
get height() {
return this.height;
}
set height(value: number) {
this.width = value;
this.height = value;
}
}
function TestRectangleArea(rect: Rectangle) {
rect.height = 5;
rect.width = 10;
if (rect.getArea() != 50) {
throw new Error("Invalid area!");
}
}
const testFigure: Rectangle = new Square();
TestRectangleArea(testFigure);
In this example we violate the principle. The last call of TestRectangleArea
will throw an error as testFigure
is actually a Square
, and Square
works different. In this case we should not derive Square
from Rectangle
.
To do things right we should follow several rules, which are:
// we can use parent class with value 200, but the child class will fail
class Account {
amount: number;
setCapital(money: number) {
if (money < 0)
throw new Error("Unable to set capital less than 0");
this.amount = money;
}
}
class MicroAccount extends Account {
setCapital(money: number) {
if (money < 0)
throw new Error("Unable to set capital less than 0");
if (money > 100)
throw new Error("Unable to set capital more than 100");
this.amount = money;
}
}
In all these cases the solution is to move the common functionality with conditions to a separate class. This way you will derive your classes from this base class, not from each other.
When you create interface you should follow this rule: a class can perform only actions that are needed to fulfil its role. Any other action should be removed completely or moved somewhere else if it might be used by another class in the future.
You don't want to add stubs for methods that are not used. It is boring, it takes you time, and adding new method to you interface will require updating all the derived classes. Yes, even the ones that don't use this new method. And don't forget about SRP!
interface IMessage {
send: () => void,
text: string,
voice: object,
subject: string,
to: string,
from: string,
}
class SmsMessage implements IMessage {
constructor(text: string, voice: object, subject: string, to: string, from: string) {}
send() {}
}
class EmailMessage implements IMessage {
constructor(text: string, voice: object, subject: string, to: string, from: string) {}
send() {}
}
class VoiceMessage implements IMessage {
constructor(text: string, voice: object, subject: string, to: string, from: string) {}
send() {}
}
interface IMessage {
send: () => void,
to: string,
from: string,
}
interface ITextMessage extends IMessage {
text: string,
}
interface IEmailMessage extends ITextMessage {
subject: string,
}
interface IVoiceMessage extends IMessage {
voice: object,
}
class SmsMessage implements ITextMessage {
constructor(text: string, to: string, from: string) {}
send() {}
}
class EmailMessage implements IEmailMessage {
constructor(text: string, subject: string, to: string, from: string) {}
send() {}
}
class VoiceMessage implements IVoiceMessage {
constructor(voice: object, to: string, from: string) {}
send() {}
}
When one class uses another class, we would like them not to depend on each other. It lowers code maintainability. Much better to use common interface, that makes it safer to change and replace parts when needed. Easy said, but probably not absolutely clear, let's just dive into examples.
class Book {
text: string;
printer: ConsolePrinter;
print() {
this.printer.print(this.text);
}
}
class ConsolePrinter {
print(text: string) {
console.log(text);
}
}
interface IPrinter
{
print(text: string);
}
class Book {
text: string;
printer: IPrinter;
constructor(printer: IPrinter) {}
print() {
this.printer.print(this.text);
}
}
class ConsolePrinter implements IPrinter {
print(text: string) {}
}
class HtmlPrinter implements IPrinter {
print(text: string) {}
}
2023, July