Can domain models be abstract classes? Sure they can. Polymorphism can be useful in the business layer of the application as well.
However, I do have a few thoughts about the design of the specific code in this question:
Why isn't IPasswordHasher
passed as a constructor parameter to Account
?
And once IPasswordHasher
is passed as a constructor parameter to a new Account
, then the abstract base class becomes unnecessary.
This isn't so much a problem with isolating business logic as it is a basic problem of managing dependencies. So to directly answer observation #2: yes, there is a problem with how the abstraction is being consumed.
There is nothing wrong with IPasswordHasher
and Account
existing in the business logic/domain layer of the application. The problem is introducing an unnecessary abstraction that can be eliminated by utilizing dependency injection.
interface IPasswordHasher {
hashPassword(password: string): string;
}
class Sha256PasswordHasher implements IPasswordHasher {
...
}
class Account {
private passwordHasher: IPasswordHasher;
constructor(passwordHasher: IPasswordHasher) {
this.passwordHasher = passwordHasher;
}
...
}
Using these classes together:
let passwordHasher = new Sha256PasswordHasher();
let account = new Account(passwordHasher);
The only useful abstraction here is the object that hashes passwords. The Account
class is cohesive and concrete. The only difference in behavior is how passwords are hashed, and a constructor parameter for IPasswordHasher
allows for that polymorphic relationship.
This doesn't mean you can never have an abstract base class with an abstract property, however an abstract base class that only contains an abstract property without other methods is immediately suspect. It's not immediately wrong, just suspect. It becomes worthwhile to rethink the design of those classes to ensure you are introducing useful abstractions that cannot be expressed by other simpler means in object oriented programming.
The downside with this approach is that you will need to refactor all of the code that initializes Account
objects. This isn't a downside of requiring a new constructor parameter, this is the downside of not structuring code for dependency injection to begin with.
As an alternative view of the problem, what if you had:
class OldAndWeakPasswordHasher implements IPasswordHasher {
hashPassword(password: string): string {
return password.toUpperCase();
}
}
Would you ever want to see:
let account = new Account(OldAndWeakPasswordHasher());
If the strength of the password hashing algorithm matters, and you only have one implementation, then consider the password hasher as a hard dependency for Account
. The password hasher becomes an implementation detail:
class Account {
private passwordHasher: Sha256PasswordHasher;
constructor() {
this.passwordHasher = new Sha256PasswordHasher();
}
}
You don't need to pass every dependency into a new object via its constructor. Sometimes you have such a hard dependency between two things that dependency injection doesn't solve any problems, and may in fact introduce problems, like using a weak cipher for passwords by accident. Just know that initializing a dependency in a constructor rather than passing it in makes it impossible to separate the behavior of Account
from the password hasher for testing purposes. If you need to mock the password hasher for testing purposes, then you are stuck with dependency injection, and interfaces or abstract classes.
A note about TypeScript and JavasScript
Many unit testing frameworks allow you to spy on and mock any object. The type system in TypeScript is an artificial constraint enforced by the TypeScript compiler, not the JavaScript runtime, so you have a little more flexibility with mocking dependencies in this runtime environment.
Although, to be fair, the type system in any strongly-typed language is an artificial constraint placed on your code by the compiler. The CPU doesn't care in most cases that a property is private. The CPU gets instructions and executes them.