Implementing Concepts in TypeScript
- Modularity in web
- Wrapping Express.js
- Concepts in TypeScript
- Persistent Storage for State
- Actions and Syncs into Web APIs
- A bit of engineering philosophy
- Resources
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
, orbody
, it’s read from thereq: 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 anyparams
andGET
requests cannot have a body, so thequery
is the only option. However, if it was aPOST
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
, andbody
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 passwordstate
registered: set User
username, password: User -> one Stringactions
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, insidePostConcept
, we can’t assumeUserT
will be similar toUserObj
and will have properties likeusername
andpassword
. In fact, as you saw, it’s possible for it to be just astring
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 usetoString
like_id1.toString() === _id2.toString()
. Inside a MongoDB filter, make sure theObjectId
is actually anObjectId
type — otherwise, it will return no results. - If the action is used as a “validator”, prefix its name with
is
orcan
(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 thesemsg
fields in your frontend (e.g., as a pop-up) without having to do additional work! Keep in mind that all errors also have amsg
field. - Correcting the point from earlier: in routes, all URL parameters and query properties must be
string
orObjectId
types (keep in mind that even though the type isObjectId
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:
- https://61040.csail.mit.edu/t/are-your-concepts-modular/240
- https://61040.csail.mit.edu/t/sets-and-relations/242
- https://61040.csail.mit.edu/t/concept-independence-specificity/248
MongoDB reference for Node.js (especially the fundamentals part): https://www.mongodb.com/docs/drivers/node/current/