In static programming languages, like Java and C#, you arrange code and interfaces in certain ways out of necessity because of restrictions the language impose on us.
If you have been programming in these languages for long, you might accidentally bring some of these practices over to new languages, not realizing you can do it differently.
Particularly, languages that support duck typing, allow us to much more easily write code that conforms to the interface segregation principle
The interface segregation principle states that no client should depend on methods it does not use.
I have seen (and written myself) lots of C# code that violates this principle, in the name of TDD. Interfaces were extracted for components in order to mock them out during unit tests. Thus some typical C# code could look like
// DataAccess.cs
public interface IUserDataAccess {
User GetUser(int);
void InserUser(User);
IEnumerable<User> GetAllUsers();
...
}
public class UserDataAccess : IUserDataAccess {
}
// UserList.cs
public class UserList {
public UserList(dataAccess: IUserDataAccess) {
// ...
}
}
But the UserList
component only needs the GetAllUsers()
function. But it has
been forced to depend on the other functions in the interface too.
This is a violation of the single responsibility principle.
Another consequence of this type of coding is, that the interface is defined to reflect the capabilities of the class. This type of design typically have a 1-to-1 mapping between methods on the interface, and public methods on the class.
But nonetheless, this is a very common pattern in C#. It's not necessarily bad. Some of the code I created using this pattern was very stable, and very well maintainable.
As with most principles, this principle should be considered more like guidelines than hard rules. But still, compliance with guidelines is a good sign that we have been designing easy-to-maintain code.
The concept of "duck typing" comes from the phrase:
If it walks like a duck and it quacks like a duck, then it must be a duck
What it means is, if I depend on a component to have the function quack()
, and
I'm passed a dependency that has the function quack()
, then I my requirements
are satisfied. My requirements doesn't dictate inheritance hierarchies or
explicit interface declaration.
Duck typing is normally associated with dynamic languages, as the duck typing behavior is inherent to the dynamic function dispatch of these. But some statically typed languages, like Go and OCaml also exhibit duck typing behavior.
Traditional OOP languages have been called aristocratic languages, they are more concerned about the heritage of a class, than what the class is actually capable of.
The type checking in typescript follows duck typing behavior.
Let's explore how we can apply these principles to some typescript code. In our scenario we have two components.
The standard logger has a bunch of methods to accommodate a large variety of use
cases, e.g. info
, error
, and fatal
.
If we had designed this system based on our Java/C# experience, we would probably have exposed the logging capabilities through an interface defined in the logging component. It could look like this:
// logging_middleware.ts
import { Logger } from "our-standard-company-logging-component";
export const createLoggingMiddleware = (log: Logger) => (req, res, next) =>
{
log.info("Request begin");
// ...
}
// app.ts
import { createLogger } from "our-standard-company-logging-component";
import { createLoggingMiddleware } from "./express-helpers";
// ... create an express app
const logger = createLogger(loggerOptions);
const middleware = createLoggingMiddleware(logger);
app.use(middleware);
This might seem like a sensible approach, but if we apply the interface
segregation principle, we can see that it violates this principle, we have been
forced a dependency to the error
and fatal
functions, even though we don't
need them.
If we were to write a unit test for the middleware, we would be forced to add these functions, even though they are not used. It could look like this:
function testLogger() {
const loggerMock = {
info: sinon.spy();
error: sinon.spy();
fatal: sinon.spy();
};
const middleware = createLoggingMiddleware(loggerMock);
// more code that runs the test
}
As we add new capabilities to our logging component, we are forced to maintain this unit test, even though nothing has changed in our middleware.
Due to typescripts duck typing rules, we don't need to use an interface known by the actual logger. So another way to write our middleware is declare an interface specifying the requirements:
export interface Logger {
debug(msg: string): void;
}
export const createLoggingMiddleware = (log: Logger) => (req, res, next) =>
{
log.debug("Request begin");
// ...
}
This seems like a small change, you may even feel that I am duplicating the interface definition, but the change is more profound.
Before, we had a dependency to the capabilities of the logging component. Now were are explicitly declaring our required dependencies.
Notice that we no longer import any dependencies.
It hasn't become any more difficult to use out standard logging component. In fact, the initialization code is exactly the same!
const logger = createLogger(loggerOptions);
const middleware = createLoggingMiddleware(logger);
app.use(middleware);
Typescript is perfectly happy to accept the logger
as a parameter to the
createLoggingMiddleware
function, because it conforms to the required
interface.
And we could even remove the interface declared in our logger component. It is no longer needed, thus there is no longer the smell of duplication.
Imagine that we are forced to make breaking changes to the logging component, how would we handle it in the two different pieces of code. Let's imagine that the ivory tower architects changed the logging component to this:
// Company logger after incompatible change
export class LogMessage {
public static fromString(msg: string) {
...
}
};
export interface Logger {
debug(msg: LogMessage): void;
}
At first we could try to compile the typescript code and react to where it reports an error.
In our original example, the compiler error would occur in the middleware itself. We would likely be inclined to adjust the middleware code to adapt to the changes. This could easily result in a change to every single line of code that does some sort of logging.
In our modified example, the compiler error would occur in the express initialization code where we construct the middleware. We might do as in the first example, but a different solution would more obviously spring to mind, to create a wrapper in the initialization code
// app.ts - after ivory tower archict change
import { createLogger, LogMessage } from "our-standard-company-logging-component";
...
const logger = createLogger(loggerOptions);
const loggerWrapper = {
debug: (msg: string) => logger.debug(new LogMessage(msg))
};
const middleware = createLoggingMiddleware(logger);
app.use(middleware);
This, in the second version, we avoided making changes to the middleware after the logger was changed. This also clearly indicates a higher decoupling in our design.
Of course, nothing prevented us from creating a wrapper in the first version, but the solution was more obvious in the second version.
Now let's say we wanted to use this middleware in other apps. We could move it to its own NPM package.
In the original version, the NPM package would need the logger package, at least as a dev-dependency, in order to have the type-checking work correctly.
In the second case, the middleware package would be completely independent of the logger package. Yet another sign of a higher decoupling.
Languages with duck typing allows you to achieve a higher level of decoupling from the implementation.
If interfaces are declared next to the components that implement them, they are likely declaring the capabilities of those components.
By moving interface declarations to the consumer of these components, you create interfaces that are more likely define your requirements.
By letting your interfaces declare the requirements of your components, you are automatically following the interface segregation principle.
This is not always the right solution, but following good software design principles tend to create more maintainable code.