26
loading...
This website collects cookies to deliver better user experience
JOIN
-ing data and reconstructing other basic functionality. But as we learned to live with it we also got a very flat performance curve and a decent handle on what works and what doesn't.DocumentClient
and a successful single-table design.async function addUserToTeams(teamIds: TeamId[], userId: UserId) {
await Promise.all(teamIds.map(async function (teamId) {
const members = await Teams.getMembers(teamId);
if (!members.includes(userId)) {
await Teams.addMember(team, user);
}
}));
}
The business logic around adding users who are already part of the team is poorly defined. Right now we simply skip the operation while other changes take place in parallel--we don't make any attempt to roll back, or notify the caller about a partial failure.
Whatever the business logic ought to be, interspersing data access throughout the implementation will make it harder to test.
Likewise, any exception-handling logic must either exist at the data layer (limiting coordination across parallel requests) or implemented as a one-off inside this method.
load-prepare-apply
pipeline implicit in the naïve implementation is a good place to start.type MembershipEffect<T> = {
type: T,
teamId: TeamId,
userId: UserId,
}
type Effect =
| MembershipEffect<'ADD_MEMBER'>
| MembershipEffect<'DEL_MEMBER'>;
// etc
'ADD_MEMBER'
effect representing a user who needs to be added.async function prepareAddUserToTeams(teamIds: TeamId[], userId: UserId) {
const effects: Effect[] = [];
await Promise.all(teamIds.map(async function (teamId) {
const members = await Teams.getMembers(teamId);
if (!members.includes(userId)) {
effects.push({ type: 'ADD_MEMBER', team, user });
}
}));
return effects;
}
async function applyMemberships(effects: Effect[]) {
await Promise.all(effects.map(function (effect) {
switch (effect.type) {
case 'ADD_MEMBER':
return Teams.addMember(team, user);
default:
throw new Error(`Not implemented: "${effect.type}"`);
}
});
}
applyMemberships
is a minimal effect processor, nothing more. It doesn't care where the events came from. It only cares that some upstream logic coughed them up, and--now that they're here--that they get applied to the application state. And since it's a pure, standalone function, it's easily extended. That could mean providing a general-purpose rollback strategy when event processing fails, or providing alternative "commit" strategies to ensure data is persisted via an appropriate API.TransactWriteItems
(if we needed transactional checks or guarantees) or BatchWriteItem
(the rest of the time) will be both safer and cheaper than adding team member in separate PutItem
requests. Making the switch is a small change to applyMemberships
: just batch up the memberships and write them simultaneously:async function applyMemberships(effects: Effect[]) {
const putItems = effects.flatMap(function (effect) {
if (effect.type === 'ADD_MEMBER') {
return [Teams.asPutMembershipItem(effect)];
}
return [];
});
// in real life we would chunk large-n batches
await docClient.batchWriteItem(putItems);
}
applyMemberships
only cares about how effects are interpreted. If we need to change how data are written, we just do it. If we need to trigger a welcome email or notify a billing service, we could add an additional effect--or, if inextricably linked to the ADD_MEMBER
effect, we can just tweak the processor to make sure they happen.prepareAddUserToTeams
just as freely. For instance, we might finish decoupling business logic and data access by preloading memberships (or any other relevant context).type Context = {
membersByTeamId: {
[teamId: TeamId]: UserId[],
},
}
async function loadMembershipContext(teamIds: TeamId[]) {
const teamMembers = await Teams.batchGetMembers(teamIds)
const pairs = teamIds.map(function (teamId, i) {
const members = teamMembers[i];
return [teamId, members];
});
return {
membersByTeamId: Object.fromEntries(pairs),
}
}
function prepareAddUserToTeams(context: Context, userId: UserId) {
const effects: Effect[] = [];
const entries = Object.entries(context.membersByTeamId);
for (const [teamId, members] of entries) {
if (!members.includes(userId)) {
effects.push({ type: 'ADD_MEMBER', team, user });
}
}
return effects;
}
load-prepare-apply
stages has yielded reusable solutions to two-thirds of any change related to the team's membership roster. Implement the business logic and you're off!load
and apply
building blocks, we can now collapse addUserToTeams
down into something much more concise:async function addUserToTeams(teamIds: TeamId[], userId: UserId) {
const context = await loadMembershipContext(teamIds);
const effects = prepareAddUserToTeams(context, userId);
await applyMemberships(effects);
}
async function addUserToTeams(teamIds, userId) {
await loadMembershipContext(teamIds)
|> prepareAddUserToTeams(%, userId)
|> await applyMemberships(%)
}
async function populateTeam(teamId: TeamIds, userIds: UserId[]) {
const context = await loadMembershipContext([teamId]);
const effects = userIds.flatMap(userId =>
prepareAddUserToTeams(context, userId)
);
await applyMemberships(effects);
}
applyMemberships
to handle DEL_MEMBER
and SET_ACCESS
effects (with some attention required around de-duplication and ordering), we could go on to implement an entire roster-management application with only minimal regard for where and how it's persisted.addUserToTeams
, our naive first implementation may be the right way to go. As users, side effects, or the surface area of our membership API increase, however, effects provide a fairly straightforward way to manage them. They might:load-prepare-apply
pipeline at the heart of most state changes isn't the big step it seems. Yes, it takes time to verify mostly-independent parts and reconstitute them into working whole. But once built, and once trusted, they tremendously accelerate future development.