This tutorial will walk you through implementing your concepts in TypeScript. Reading it carefully is very crucial for A4 and the later assignments.

Modularity in web

If you already have web development experience, some of this may sound unfamiliar to you. Most of it, however, is just a straightforward application of modularity principles that you already know (e.g., from 6.102/6.031), combined with the extra modularity that concepts bring. At the end of this note, some of the rationale is discussed more.

Wrapping Express.js

Before we get into implementing concepts, let’s understand the way we’ll be implementing the web routes for context. The framework we provide allows an easy and nice way to implement web APIs. If you have used Express before, you know how it’s annoying to deal with req and res or forget calling next() in a middleware. Handling errors in express is another big headache too — in addition to having to do return right after sending a response, you might remember asyncHandler from 6.031/6.102. We solve these problems by adding a wrapper over Express.js.

Router from framework/router.ts provides functionality to decorate TypeScript methods with the method and the route, like @Router.get("/users"). Refer to Lecture 6 slides to get a refresher on designing APIs. The methods either throw errors or return results, both of which will be sent to the caller as a response.

For example, this is how we would create a super simple random number generator between 1 and 100:

class Routes {
  @Router.get("/random")
  async getRandomNumber() {
    return Math.floor(Math.random() * 100);
  }
}

If we want to provide, for example, query parameters like /random?min=40&max=150, we can easily do that by adding min and max to the function parameters (note that query parameters are always of string type, so we would need to do the casting ourselves):

class Routes {
  @Router.get("/random")
  async getRandomNumber(min: string, max: string) {
    const minNum = min ? parseInt(min) : 0; // if not given, default to 0
    const maxNum = max ? parseInt(max) : minNum + 100; // if not given, default to minNum + 100
    return Math.floor(Math.random() * (maxNum - minNum)) + minNum;
  }
}

Here comes the best part! Let’s say, we want to do some error handling. If min and max are not numbers (e.g., /random?min=abc), we want to send an error message. We can simply throw an error:

class Routes {
  @Router.get("/random")
  async getRandomNumber(min: string, max: string) {
    const minNum = min ? parseInt(min) : 0;
    const maxNum = max ? parseInt(max) : minNum + 100;
    if (isNaN(minNum) || isNaN(maxNum)) {
      throw new Error("Invalid min/max!");
    }
    return Math.floor(Math.random() * (maxNum - minNum)) + minNum;
  }
}

The framework uses HTTP_CODE property on an error class to send a status code, and the default is 500 Internal Server Error. You can find useful errors under concepts/errors.ts to use, but let’s define one here as well:

class BadValuesError extends Error {
  public readonly HTTP_CODE = 400;
}

class Routes {
  @Router.get("/random")
  async getRandomNumber(min: string, max: string) {
    const minNum = min ? parseInt(min) : 0;
    const maxNum = max ? parseInt(max) : minNum + 100;
    if (isNaN(minNum) || isNaN(maxNum)) {
      throw new BadValuesError("Invalid min/max!");
    }
    return Math.floor(Math.random() * (maxNum - minNum)) + minNum;
  }
}

This kind of error handling especially comes super useful when in your route you call another function that throws an error — because now you don’t need to catch it and forward it to res object from Express. Now, let’s understand more exactly how this framework works.

Router.get(endpoint) represents a handler for a GET method request to the endpoint. Other methods are also supported. The parameter names (e.g., min, max in case of above) of the decorated are read from the web request directly. Here’s how it works:

  • If the parameter name is session, then it will represent the express-session object (i.e., req.session). This session is persisted through the program runtime for the same requester (determined by cookies).
  • If parameter name is params, query, or body, it’s read from the req: Request object directly. Remind yourself of them from 6.102 notes.
  • Otherwise, it will be searched in these in that order: req.params, req.query, req.body. E.g., for the /random route above, there are no any params and GET requests cannot have a body, so the query is the only option. However, if it was a POST request for some reason (not a good idea, though), having the parameters either in the query or body would be fine!

As you saw above, if there are errors thrown during the handling, the error will be sent to the front-end with its message and status code (more about this in a later section!).

Let’s now implement what would look like a login, editPost, and createPost routes in an app where there is no authentication (you can login to any username). The implementation for these ones are only to understand better how the route handling works and some details are left out, but we discuss the actual way to code them in a later section. It’s still a correct code!

// `posts` is a mapping from post id to the post
const posts = new Map<string, { author: string; content: string }>();
let nextId = 0;

// `session` directly comes from `req` object
@Router.post("/login")
async login(session, username: string) {
  session.user = username;
}

// `content` might either come from query or body
@Router.post("/posts")
async createPost(session, content: string) {
  if (!session.user) throw new UnauthenticatedError("Log in first!");
  const user = session.user;
  posts.set(
    nextId.toString(), // id of this post
    { author: user, content } // the post itself
  );
  nextId++;
}

// `id` in the method comes from the URL parameter `:id`
@Router.patch("/posts/:id")
async editPost(session, id: string, newContent: string) {
  if (!session.user) throw new UnauthenticatedError("Log in first!");
  if (!posts.has(id)) throw new NotFoundError("Post not found!");
  const post = posts.get(id);
  if (post.author !== session.user) throw new NotAllowedError("Nope!");
  post.content = newContent;
}

Keep in mind that due to technical limitations of Express.js and TypeScript, query and URL parameters can only be string types. We slightly break this rule in a below section, but it’s explained how and why. The body of the request, however, can be anything JSON supports (e.g., a parameter of type Array<string>). TypeScript is not really a strongly typed language — your code will still compile and run even though, e.g., you put number for a type that is actually a string. Since it’s converted into JavaScript, all the types are going to be ignored in the runtime. So, be careful with these!

Here’s a recap:

  • Use @Router.httpMethod(endpoint) decorators on functions to declare web APIs.
  • session, params, query, and body are special keywords and they will be read from the request directly (e.g., params would represent URL parameters).
  • All other parameter names are exported from these in that order: req.params, req.query, req.body.
  • Use string for URL params and queries, but body can be any JSON-supported type.

Now that you have a (hopefully!) better understanding of how route handling works, let’s switch over to implementing concepts and synchronizations, which then will be converted into routes like above!

Concepts in TypeScript

Implementing Concepts

One thing we have learned from the class is that concepts are completely modular and self-contained. This means, if we have Post and Comment concepts, they should be able to work together without even knowing that the other exists. In this framework, we show you a paradigm to achieve this kind of modularity in our backend code. Keep reading and you will find out why this is much better than the traditional way of implementing backends.

Before we even get to the “web” part, we want to be able to implement concepts. While the big idea behind concept-driven development go beyond basic OOP, classes provide a good way to implement concepts. Let’s look at the User concept from Lecture 4.

concept User

purpose Authenticate users

principle
after a user registers with a username and password,
they can authenticate as that user by providing a matching
username and password

state
registered: set User
username, password: User -> one String

actions
register (username, password: String, out u: User)
authenticate (username, password: String, out u: User)

Let’s now implement this in TypeScript:

interface UserObj {
  username: string;
  password: string;
}

/**
 * Purpose: Authenticate users
 * Principle: after a user registers with a username and password, they can
 * authenticate as that user by providing a matching username and password
 */
class UserConcept {

  // State (registered -> UserObj)
  private registered: Array<UserObj>;

  public register(username: string, password: string): UserObj {
    const existing = this.registered.find((user) => user.username === username);
    if (existing !== undefined) {
      throw new Error("This username is taken!");
    }

    const u: UserObj = { username, password };
    this.registered.push(u);
    return u;
  }

  public authenticate(username: string, password: string): UserObj {
    const u: UserObj = { username, password };
    if (this.registered.indexOf(u) === -1) {
      throw new Error("Username or password is wrong!");
    }
    return u;
  }
}

The UserConcept class is actually a generator of user concepts, so we start by creating an instance of this class:

const User = new UserConcept();

We can now easily call actions register and authenticate like this:

User.register("barish", "salam123");

try {
  User.authenticate("barish", "salam111");
  console.log("Auth succeeded!");
} catch (e: unknown) {
  console.log(`Auth failed: ${e}`);
}

Now, to make things a bit more interesting, let’s also create a Post concept (a simplified version of a social media post concept). Note that the UserT we use here is a generic type (strictly, a type variable) and has nothing (so far) to do with the UserConcept class concept.

interface PostObj<UserT> {
  author: UserT;
  content: string;
  _id: number; // a post is identified by _id, they are interchangable
}

/**
 * Purpose: share content
 * Principle: a basic CRUD concept:
 *   After a user creates a post, it's visible to others
 */
class PostConcept<UserT> {
  private posts: Array<PostObj<UserT>>;
  private nextId = 0; // used to add ids to the posts

  public create(author: UserT, content: string) {
    const postObj: PostObj<UserT> = { author, content, _id: this.nextId });
    this.posts.push(postObj);
    this.nextId++;
    return { msg: "Post created!", postObj };
  }

  public get(post: number) {
    return this.posts.find((postObj) => postObj._id === post);
  }

  public edit(post: number, newContent: string) {
    const postObj = this.get(post);
    if (postObj === undefined) {
      throw new Error("Post not found!");
    }
    postObj.content = newContent; // post is a reference to the post inside this.posts
    return { msg: "Post edited!" };
  }
}

When defining our app, we would instantiate this concept like Post[User.User], so let’s do it similarly for the PostConcept with UserObj from earlier:

const Post = new PostConcept<UserObj>();

Now we can use these concepts together:

const u = User.authenticate("barish", "salam123");
const { msg, postObj } = Post.create(u, "hi!");

Since the UserT type PostConcept takes in is generic, we could also use, for example, string representing the username:

const Post = new PostConcept<string>();

This allows us to easily make posts like Post.create("barish", "hi") without passing in the whole user object from User concept. However, this is probably not what you want because now you can’t let users change their usernames! This problem doesn’t come up when author is a reference to the UserObj instance, so we would ideally prefer that.

It might also be tempting to call Post.create like this:

const { msg, postObj } = Post.create({ username: "barish", password: "123" }, "hi!");

This is bad for two reasons. One, we are creating a new UserObj type object that will not match the user object User has in its state. If you remember it from 6.102, equality checking for non-primitive types in TypeScript is complicated and even has its own whole reading. Thus, postObj.author === User.getByUsername("barish") will be false for the code above (assuming getByUsername returns UserObj matching the given username), making it impossible to get posts by given user. Two, we can’t just go around and create UserObj as we want — we are breaking the encapsulation provided of UserConcept by doing that.

This was a lot! Here are some key points to recap what we talked in this section so far:

  • We implement concepts using TypeScript classes. Keep in mind that classes are slightly different than the examples of ADTs you might have seen in 6.102/6.031: UserConcept instance represents a concept that manages all users, not just a single user.
  • A lot of times, concepts will have variable types, like PostConcept. We use TypeScript generics for these. Note that inside the concept implementation, we can’t make any assumptions about this type — for example, inside PostConcept, we can’t assume UserT will be similar to UserObj and will have properties like username and password. In fact, as you saw, it’s possible for it to be just a string too.
  • To use concepts, we instantiate them first and then call their actions. Most times, concepts are used together — this is what we call synchronizations, the topic of the next section.

Concept Synchronizations

Let’s take a step back and look closer to these Post.create and Post.edit actions. You might ask, how come we are allowed them to be used arbitrarily and don’t have to check if the one who’s calling it (e.g., logged in user) is really the author of that post?

From PostConcept’s perspective, there are no permissions or rules about who edits the post. This is an important separation of concerns. Moreover, the Post concept doesn’t even know about users, except that that some type UserT contains the objects that play the role of authors of posts.

If Post can’t make such an important decision about who is the one creating or editing the post, how will we implement it correctly? That’s where synchronization comes in.

Let’s say, we also require username and password to create a post. Then we would sync User and Post like this:

/**
 * sync createPost(username, password, content):
 *   when User.authenticate(username, password, [out] user)
 *   Post.create(user, password, content)
 */
function createPost(username: string, password: string, content: string) {
  // If authentication fails, User.authenticate will throw an error.
  // Thus, it will not allow to create post without proper auth.
  const user = User.authenticate(username, password);
  return Post.create(user, content);
}

Notice here how we are using User concept to simply provide authentication, as we discussed in Lecture 4. In a web application, it wouldn’t be as convenient to provide a username and password for every interaction, so we use a WebSession concept as you saw both in Lecture 4 and Prep 2. This is how we would implement it with the session instead:

/**
 * sync createPost(session, content):
 *   when WebSession.getUser(session, [out] user)
 *   Post.create(user, content)
 */
function createPost(session: WebSessionObj, content: string) {
  const user = WebSession.getUser(session);
  return Post.create(user, content);
}

Now, let’s also see how editPost works out. We need some way of verifying that the user from WebSession is the author of post that’s being edited. One nice way to do this is to simply get the post object and check its author:

function editPost(session: WebSessionObj, post: number, newContent: string) {
  const user = WebSession.getUser(session); // we store username in WebSession
  const postObj = Post.get(post);
  if (postObj.author !== user) {
    throw new Error(`${user} is not the author of post ${post}`);
  }
  return Post.edit(post, newContent);
}

This could also be made into an action in Post concept that serves to check if given user is the author of a post:

// inside PostConcept class
public isAuthor(user: UserT, post: number) {
  const postObj = this.posts.find((postObj) => postObj.id === post);
  if (!postObj.author === user) {
    throw new Error(`${user} is not the author of post ${id}`);
  }
}

Notice that isAuthor does not return anything or do any changes to the state — it is used as what you might call a “validator.” It could seem weird that we call it an action, but note that we still use the state to decide if an error needs to happen or not — we simplify our workflow by throwing errors rather than returning a boolean that tells us if the author is permitted to edit the post or not. This is like the permit action defined in the Karma concept here.

Now we would also be able to implement a front-facing editPost function like this as well:

function editPost(session: WebSessionObj, post: number, newContent: string) {
  const user = WebSession.getUser(session);
  Post.isAuthor(user, post);
  return Post.edit(post, newContent);
}

Both ways are legal in terms of concept modularity, but as we discussed, it’s not the responsibility of Post to check for ownership, so you might find the first way more intuitive. However, as you add more actions, you might find yourself doing this check often, so sometimes it’s useful to have it as an action like above as well.

If you have any questions about anything above, please ask right away! I hope this was helpful in understanding the main pattern we will follow.

Persistent Storage for State

Notice that the states we had in our program live in the memory (RAM) of the computer they are running on, and we would lose all of it if the program shuts down. So, we need a persistent storage method, i.e., a database. In this class, for its convenience and good integration with TypeScript, we’ll be using MongoDB and its official driver for Node.js. Since MongoDB calls objects “documents”, we’ll follow the same convention and call interfaces UserDoc, PostDoc, etc. instead of UserObj or PostObj. We also provide a wrapper around the MongoDB driver in framework/doc.ts for your convenience. It’s still recommended and very useful to read the fundamentals documentation.

When we switch to a database, we lose one crucial invariant we had: we can’t rely on object references for equality anymore. For example, previously, we could be sure that when we create a post with author being UserObj, that object will be the same object as the one in User concept.

const u = User.authenticate("barish", "123");
const { msg, postObj } = Post.create(u, "my post!");
assert(postObj.author === u); // true!

When we store things in a database, we load them as we need, so they won’t have the same reference. In fact, different databases will store them differently, so there is really no way to know if the references will match or not. We solve this problem by using a unique identifier per document MongoDB will create for us, which will be of type ObjectId. Thus, in Post concept, we don’t need to make it take in a generic variable anymore — we can just use ObjectId in PostDoc definition. In addition, we provide a utility BaseDoc interface and DocCollection class to handle all database operations in framework/doc.ts.

BaseDoc will provide fields _id, dateCreated, and dateUpdated, and all of these are managed by DocCollection — you should not be modifying them directly.

Here’s how we would implement PostConcept with given changes.

interface PostDoc extends BaseDoc {
  author: ObjectId;
  content: string;
}

class PostConcept {
  private posts = new DocCollection("posts"); 

  public create(author: ObjectId, content: string) {
    const _id = await this.posts.createOne({ author, content, options });
    return { msg: "Post created!", post: await this.posts.readOne({ _id }) };
  }

  public get(post: ObjectId) {
    return this.posts.readOne({ _id });
  }

  public edit(post: ObjectId, newContent: string) {
    await this.posts.updateOne({ _id }, { content: newContent });
    return { msg: "Post edited!" };
  }
}

Concepts are not limited to having single collections. We also provide Friend concept in the starter code, so let’s take a quick look at how it’s designed. We store both the friendships and friend requests in the concept. If (u1, u2) friendship exists, that means u1 and u2 are friends ((u2, u1) friendship doesn’t need to exist — in fact, the full code only allows exactly one of these pairs to exist). We can also make nice use of MongoDB’s convenient filters, as you can learn from its documentation and in Recitation 4. Take a look at how getRequests gets all friend requests for a given user.

export interface FriendshipDoc extends BaseDoc {
  user1: ObjectId;
  user2: ObjectId;
}

export interface FriendRequestDoc extends BaseDoc {
  from: ObjectId;
  to: ObjectId;
  status: "pending" | "rejected" | "accepted";
}

export default class FriendConcept {
  public readonly friends = new DocCollection<FriendshipDoc>("friends");
  public readonly requests = new DocCollection<FriendRequestDoc>("friendRequests");

  // Get all requests from or to the user
  async getRequests(user: ObjectId) {
    return await this.requests.readMany({
      $or: [{ from: user }, { to: user }],
    });
  }

  // ... (rest omitted)
}

Concept Reuse with MongoDB

Since concepts are generic, we can also reuse them very easily. For example, let’s say we have a Comment concept and we allow commenting on Post. Inside CommentDoc, we should have a field target: ObjectId (instead of post: ObjectId, since target is more generic). We then decide that we want to allow commenting on User (i.e., making a comment about a user), so we can just reuse the comment concept.

In fact, you could first try to use a single instantiation for both posts and comments, like this:

const CommentPostUser = new CommentConcept();

But that’s not good practice. We don’t want to mash up data that belongs to different places into the same collection. Let’s try to separate them by simply instantiating the concept twice:

const CommentPost = new CommentConcept();
const CommentUser = new CommentConcept();

This actually won’t quite work at first.

Look at how collections get their names:

export default class FriendConcept {
  public readonly friends = new DocCollection<FriendshipDoc>("friends");
  // ...
}

In the database, we don’t have any separation between comments on users vs posts, so it would be really hard to maintain this structure. We need to create different collection names for the two concept instances. Do that by defining constructors (exercise for the reader), so you can write

const CommentPost = new CommentConcept("comments_on_posts");
const CommentUser = new CommentConept("comments_on_users");

Note that the names we give to collections don’t really matter technically.

Actions and Syncs into Web APIs

In the beginning of this document, you learned how to write web routes that work with simple functions. Later, you learned how to implement concepts actions or synchronizations which are also just functions. Now, it’s time to combine both!

For example, this is how we would create a couple of routes for getting and creating users:

class Routes {
  @Router.get("/users")
  async getUsers(username?: string) {
    return await User.getUsers(username);
  }

  @Router.post("/users")
  async createUser(session: WebSessionDoc, username: string, password: string) {
    WebSession.isLoggedOut(session);
    return await User.create(username, password);
  }
}

Here’s how we would implement a route for sending a friend request:

  @Router.post("/friend/requests/:to")
  async sendFriendRequest(session: WebSessionDoc, to: ObjectId) {
    await User.userExists(to);
    const user = WebSession.getUser(session);
    return await Friend.sendRequest(user, to);
  }

As you can see, to is expected from the URL parameter, and we synchronize 3 actions: make sure that such to user exists, get the current user from the session, and send the friend request from user to to.

As an example of another sync, let’s say I want my app to make a post every time I log in. Here’s how I might do it:

@Router.post("/login")
async login(session: WebSessionDoc, username: string, password: string) {
  const user = await User.authenticate(username, password);
  WebSession.start(session, user._id);
  return await Post.create(user._id, "Hi, I just logged in!");
}

Keep in mind that routes are usually expected to be atomic — so, make sure to do validations first so you don’t run into unintended side effects.

Notice that we are using some parameters like to as of type ObjectId above, when they are actually a string (remember from the earlier section), but the DocCollection handles conversion for your convenience (to some extent). Most of the things will work, but be careful with this — for example, you shouldn’t directly pass in ObjectId (disguised as string) from a route to the following method since it assumed ObjectId type in the filtering.

  async getRequests(user: ObjectId) {
    return await this.requests.readMany({
      $or: [{ from: user }, { to: user }],
    });
  }

However, this one is perfectly fine because WebSession.getUser always returns ObjectId.

  @Router.get("/friend/requests/")
  async sendFriendRequest(session: WebSessionDoc) {
    const user = WebSession.getUser(session);
    return await Friend.getRequests(user);
  }

If you are confused about this, make sure to ask questions!

Response Formatting

Since concepts are generic, the data they return will sometimes contain information that the front-end user cares less about. For example, let’s look into this route:

  @Router.get("/posts")
  async getPosts() {
    return await Post.getPosts();
  }

Note that this will return an array of PostDoc, and the author field inside them is ObjectId. While the front-end developer could make another request for converting those into usernames using another route (e.g., getUsernames(ids: ObjectId[]): string[]), it’s a better idea to solve this in the server side to decrease the amount of complexity in the front-end. For this, we implement Response handlers in responses.ts that reformat responses. For example:

export default class Responses {
  /**
   * Convert PostDoc into more readable format for the frontend
   * by converting the author id into a username.
   */
  static async post(post: PostDoc | null) {
    if (!post) {
      return post;
    }
    const author = await User.getUserById(post.author);
    return { ...post, author: author.username };
  }

  /**
   * Same as {@link post} but for an array of PostDoc for improved performance.
   */
  static async posts(posts: PostDoc[]) {
    const authors = await User.idsToUsernames(posts.map((post) => post.author));
    return posts.map((post, i) => ({ ...post, author: authors[i] }));
  }
}

and then we can do

  @Router.get("/posts")
  async getPosts() {
    return await Responses.posts(await Post.getPosts());
  }

Error Formatting

In concepts, we would like to avoid having web parts as much as possible. However, it would be cumbersome to catch every different type of error in routes.ts and handle them separately. Thus, we provide error templates in concepts/errors.ts you can directly use or extend from that will automatically be sent to the front-end by the framework. For example, here’s a usage of it:

// in UserConcept class
  async logIn(username: string, password: string) {
    const user = await this.users.readOne({ username, password });
    if (!user) {
      throw new NotAllowedError("Username or password is incorrect.");
    }
    return { msg: "Successfully logged in.", _id: user._id };
  }

If you try to log in with the wrong username or password, you’ll get a status code of 403 and the following JSON as a response.

{
  "msg": "Username or password is incorrect."
}

Now, same as we saw earlier with response formatting, we would like to have formatting for errors as well. For example, the error might say ${author} is not the author of post ${id}. In this case, author will be ObjectId, but we would like to convert it into a username for so the frontend is happy. For this, we define our own error where we can access author and id separately:

// in concepts/post.ts
export class PostAuthorNotMatchError extends NotAllowedError {
  constructor(
    public readonly author: ObjectId,
    public readonly _id: ObjectId,
  ) {
    // {0} and {1} will be replaced by corresponding arguments
    super("{0} is not the author of post {1}!", author, _id);
  }
}

Then in the action, we can use this error like this:

// inside PostConcept
  async isAuthor(user: ObjectId, _id: ObjectId) {
    const post = await this.posts.readOne({ _id });
    if (!post) {
      throw new NotFoundError(`Post ${_id} does not exist!`);
    }
    if (post.author.toString() !== user.toString()) {
      throw new PostAuthorNotMatchError(user, _id);
    }
  }

Now that we can access author and _id fields separately in PostAuthorNotMatchError, we can also format it. To do this, in responses.ts, we register the error for handling:

Router.registerError(PostAuthorNotMatchError, async (e) => {
  const username = (await User.getUserById(e.author)).username;
  return e.formatWith(username, e._id); // replace first arg with username
});

This will magically handle an error of this type and convert the user id in the field e.author to a username!

Here are some key points to keep in mind:

  • Use _id when referring to id of a specific document in MongoDB collection.
  • When comparing two ObjectId types in TypeScript, always make sure to use toString like _id1.toString() === _id2.toString(). Inside a MongoDB filter, make sure the ObjectId is actually an ObjectId type — otherwise, it will return no results.
  • If the action is used as a “validator”, prefix its name with is or can (e.g., isAuthor, canCreatePost, etc.).
  • If the action has a side effect (i.e., makes changes to the database), include a msg property in your response. This way, you can also directly show these msg fields in your frontend (e.g., as a pop-up) without having to do additional work! Keep in mind that all errors also have a msg field.
  • Correcting the point from earlier: in routes, all URL parameters and query properties must be string or ObjectId types (keep in mind that even though the type is ObjectId it’s a string — the database wrapper handles the conversion). The request body can contain other types like numbers and arrays (i.e., JSON).

A bit of engineering philosophy

Modularity and separation of concerns are one of the most important ideas of excellent engineering work. Of course, sometimes modularity has to be partially sacrificed for performance, but poor modularity leads to so many problems that big companies (Google, Microsoft, etc., and even trading companies) work hard to achieve as much modularity as possible. This is because coupling exponentially increases tech debt and makes it harder to maintain the code base and its separate elements. For example, if we kept comments inside posts, then we would be forcing some business logic in our data types – if you delete a post, comments now must be deleted. That would rule out the common behavior of some apps (e.g., Reddit) that allow independent deletion of posts and comments.

The way we implement concepts separates them from the business logic (unless we really want to bake that logic inside the concept). For example, we can choose to delete or keep comments on post deletion. Or if we decide that there are now User Roles in our app and an admin role can also edit other’s posts, we don’t need to touch any existing concept code! We just implement a new concept and modify a few lines in editPost route. I personally find that super cool!

Another very important principle, as you know from 6.031/6.102 is the importance of testing. Due to work load, we are not enforcing unit tests in your implementations, however, notice how easy it would be to test concepts and interactions between them. They are just normal functions you can call in your code, without simulating any kind of network requests (remember tests from Memory Scramble in 6.102 — you had to start the server and do fetch).

To sum up, we think this is an elegant way to implement concepts and web routes in TypeScript while making sure they are modular and the concerns are separated. If you have any suggestions, or come up with a better way, please chat with us!

Resources

Daniel’s concept tutorials: https://essenceofsoftware.com/tutorials/

Lecture and Recitation notes: https://61040-fa23.github.io/schedule

Helpful Discourse discussions:

MongoDB reference for Node.js (especially the fundamentals part): https://www.mongodb.com/docs/drivers/node/current/