Trabe
Asís & David
https://trabe-teaching.github.io/strike-your-balance
Context
- We develop front and back with JS (15+ years)
- We have developed with many stacks: RoR, JEE, .NET
- We journied from metamagic to explicitness
Motivation
- IDHO (In David's humble opinion)
- Developers struggle with abstractions
Strike your balance
...or the search for the right abstraction
About the code examples
Javascript, mostly naive, can be unsafe,
no imports/exports
Abstraction in computer science is the process of removing elements of a code or program that aren't relevant or that distract from more important elements. Abstraction refers to looking at something to maintain the general form or meaning while reducing the presence of specific details.
Google 🤷🏻♀️
An abstraction is defined
by its APIs
How do I use it. What do I get
Example
Read a file into a string, three languages, three levels of abstraction
// C
int file = open("example.txt", O_RDONLY);
struct stat file_info;
fstat(file, &file_info);
char *buffer = (char *)malloc(file_info.st_size + 1);
read(file, buffer, file_info.st_size);
buffer[file_info.st_size] = '\0';
close(file);
// js - node
const buffer = await fs.readFile("some.txt");
const text = buffer.toString();
# ruby
text = File.open("some.txt").read
You can't have everything!
- Every option has pros and cons
- High level: easy to use, easy to understand
- Low level: highly customizable, fares better with custom use cases
Every abstraction has tradeoffs
This talk's mantra
Real world example
Dependency injection
Option 1: Explicit dependencies
function updateUserPreference({ key, value, repos }) {
// Do stuff with the user
// Save the updated user
repos.userRepo.update(updatedUser);
}
const repos = {
userRepo: userRepo(),
};
updateUserPreference({ key: "someKey", value: "someValue", repos });
// updateUserPreference({ key: "someKey", value: "somevalue", repos: { userRepo: userRepoDb() })
// updateUserPreference({ key: "someKey", value: "somevalue", repos: { userRepo: userRepoKS() })
// updateUserPreference({ key: "someKey", value: "somevalue", repos: { userRepo: logProxy(userRepo()) })
it("updates the user preference", () => {
const mockUpdate = jest.fn();
const mockRepo = { update: mockUpdate };
const repos = { userRepo: mockRepo };
updateUserPreference({ key: "someKey", value: "someValue", repos });
expect(mockUpdate).toHaveBeenCalledWith(/* the updatedUser */);
});
Option 2: Implicit dependencies
function updateUserPreference({ key, value }) {
// Do stuff with the user
// Save the updated user
const repo = userRepo();
repo.update(updatedUser);
}
updateUserPreference({ key: "someKey", value: "someValue" });
const mockUpdate = jest.fn();
jest.mock("user-repo.js", () => () => ({
update: mockUpdate;
}));
it("updates the user preference", () => {
updateUserPreference({ key: "someKey", value: "someValue" });
expect(mockUpdate).toHaveBeenCalledWith(/* the updatedUser */);
});
function defaultRepos() {
return {
userRepo: userRepo(),
};
};
function updateUserPreference({ key, value, repos = defaultRepos() }) {
// Do stuff with the user
// Save the updated user
repos.userRepo.update(updatedUser);
}
updateUserPreference({ key: "someKey", value: "someValue" });
it("updates the user preference", () => {
const mockUpdate = jest.fn();
const mockRepo = { update: mockUpdate };
const repos = { userRepo: mockRepo };
updateUserPreference({ key: "someKey", value: "someValue", repos });
expect(mockUpdate).toHaveBeenCalledWith(/* the updatedUser */);
});
You can't have everything!
- Explicit: no magic, easy to grasp, easy to test
- Implicit: convenience, less boilerplate
- Both: convenience, "easy to test", double abstraction OMG!
Every abstraction has tradeoffs
Abstraction budget
- Less boilerplate in your app code: more in your tests
- Easier to work with: harder to debug
- Easier to grasp: harder to really understand
- AOP
- Metaprogramming
- AST transformations
Real world example
A very common React abstraction
const Context = React.createContext(0);
function Component() {
const value = React.useContext(Context);
return <span>value is {value}</span>;
}
<Context.Provider value={5}>
<Component />
</Context.Provider>
// Renders "value is 5"
const StorageContext = React.createContext();
function StorageProvider({ storage, children }) {
return (
<StorageContext.Provider value={storage}>
{children}
</StorageContext.Provider>
);
}
function useStorage() {
const storage = React.useContext(StorageContext);
if (!storage) {
throw new Error("Cannot use useStorage outside of an StorageProvider");
}
return storage;
}
function useStoredState(key, initialValue) {
const storage = useStorage();
const [value, setValue] = React.useState(() => {
return storage.get(key) ?? initialValue;
});
const setStoredValue = (newValue) => {
setValue(newValue);
storage.set(key, newValue);
};
return [ value, setStoredValue ];
}
function Counter() {
const [count, setCount] = useStoredState("count", 0);
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
<StorageProvider storage={window.localStorage}>
<Counter />
</StorageProvider>
test("It works", () => {
render(
<StorageProvider storage={new Map()}>
<Counter />
</StorageProvider>
);
});
test("Does not work", () => {
render(<Counter/>);
});
// Error: "Cannot use useStorage outside of an StorageProvider"
function useStorage() {
const storage = React.useContext(StorageContext);
if (!storage) {
console.error("Cannot use useStorage outside of an StorageProvider");
}
return storage;
}
const testStorage = new Map();
function useStorage() {
const storage = React.useContext(StorageContext);
if (!storage && process.env.NODE_ENV === "test") {
return testStorage;
}
if (!storage) {
throw new Error("Cannot use useStorage outside of an StorageProvider");
}
return storage;
}
function useStorage({ skipCheck }) {
const storage = React.useContext(StorageContext);
if (!storage && !skipCheck) {
throw new Error("Cannot use useStorage outside of an StorageProvider");
}
return storage;
}
function useStoredState(key, initialValue) {
const storage = useStorage({ skipCheck: true });
// ...
const StorageContext = React.createContext(new Map());
function useStorage() {
return React.useContext(StorageContext);
}
You can't have everything!
- Improved safetiness implies extra constraints
- Maybe avoid assuming how errors should be handled
- What do your users really need?
- Try to not reinvent the wheel (i.e. just use React context default value)
Every abstraction has tradeoffs
Real world example
To couple or not to couple
const log = logger("errorMiddleware");
function errorMiddleware(ctx, next) {
try {
await next();
} catch(e) {
log.error("Something went wrong", e.message);
log.debug(e.stack);
ctx.status = 500;
}
}
loggers:
base:
level: INFO
transports:
- console
errorMiddleware:
level: DEBUG
And then, came the lambda function
async function azureFn(request, context) {
const log = logger("azureFun"); // NOPE
try {
// Stuff
} catch(e) {
log.error("Something went wrong", e.message);
log.debug(e.stack);
return {
status: 500,
};
}
}
async function azureFn(request, context) {
const log = logger({
level: process.env.LOG_LEVEL,
transports: [ azureLogTransport(context) ],
});
// ...
loggers:
base:
level: INFO
errorMiddleware:
level: DEBUG
const log = logger({
...config.loggers.base,
...config.loggers.errorMiddleware,
transports: [ consoleTransport() ],
});
loggers:
base: &base
level: INFO
errorMiddleware:
<<: *base
level: DEBUG
const log = logger({
...config.loggers.errorMiddleware,
transports: [ consoleTransport() ],
});
But you've lost the option to configure the transport in the YAML?
const transportFactories = {
console: () => consoleTransport(),
};
function coupledLogger(key) {
const loggerConfig = {
...config.loggers.base,
...config.loggers[key],
};
const transports = loggerConfig.transports.map((t) => transportFactories[t]());
return logger({ ...loggerConfig, transports });
}
const log = coupledLogger("errorMiddleware");
You can't have everything!
- Foundations: explicitness and dependency injection
- Build commodities using the foundations
- Use plain code if you can
Every abstraction has tradeoffs
You can't have everything!
- Do not start with the abstraction. Extract later
- Think in terms of APIs and client code (and target users 😅)
- Explicit is almost always better than implicit
- Avoid boilerplate with extra layers
- Beware of the red flags:
storage: "memory"
vs storage: memoryStorage()
- Listen to your tests and docs
Duplication is far cheaper than the wrong abstraction
Sandi Metz
The biggest risk of tool development is accidentally convincing yourself that
incidental complexity your abstraction created is essential complexity of the
problem space.
And then you think workarounds for the incidental complexity are features
you’re proud of.
Ryan Florence - Remix fame
Every abstraction has tradeoffs
This is the last time!
Strike your balance
Use the right abstraction