Blabbering on about SaaS

Nazarii Marko
9 min readJan 10, 2023

--

I love stock photos

So I had an idea recently and, dare I say, quite an interesting one. And, obviously, nobody’s going to stop you from arguing that all my ideas are interesting to me, it makes sense. But stick with me for a sec.

SaaS! Heard of this bad boi? It stands for Software-as-a-Service, which, if I’m being honest, speaks for itself. It’s an interesting approach to building software, where instead of “selling” an app — your app is a platform for selling some service. And SaaS doesn’t give two shits about what services you provide, it can easily be Netflix 2.0 (would it even be 2.0?) or medium 3.0. It’s pretty much the same.

Oki, so we’ve decided to build the app, we’ve decided that we want to give our users a platform to express themselves and to deprive them of a few shekels (after all, why shouldn’t I keep it?). Sounds pretty easy, right? Wrong. Nothing’s easy when it comes to infrastructure, so tune up your decision-making apparatus and brace yourself for the impact, because we’re going in, just like Peter Tagtgren!

First things first — who do we trust? I mean this question literally — these days all major platforms provide some kind of SaaS support, and they all are pretty much the same and the only factor that matters is your preference. So who will it be, Jeffrey, Bill, or Larry? (Jeffrey for me ❤)

Next thing — how do we settle multiple “tenants” on our platform? Should we “spin up” a separate infrastructure for each user? User data would be secure, but do we really care? Is this the type of app that needs extensive security? Do our users need their data to be hosted separately from other tenants? I don’t think so. Also, we are building an app to grab only a few shekels, so the cheaper infra maintenance the better. By the way, this approach is called “Silo Isolation”. Not that we’ll need it, but feel free to look it up. Amazon does a great job explaining different isolation approaches, so praise where it’s deserved. But on a side note I’d love to highlight that I do enjoy AWS, it’s a powerful platform with the right tools for literally any situation. But the good lord above us must have turned a blind eye when this abomination of a UX was created. How do you mess up your learning platform that badly? How the fuck would anyone think “yeah, we’ve done a helluva job with this one, keep it up, boiz!”. AWS, please do something with it, for the love of god.

abomination of a UX
The abomination of a UX

Back to our tough decisions. We already know that Silo Isolation isn’t what we’re looking for. So we’ll stick with something easier (easier in a way that we don’t need to have a fucking stroke over working extensively with AWS SDK). So as I said, let’s keep it simple, we could give subdomains to our subscribers and make an illusion of what we were discussing earlier. That shouldn’t be too much work, a single DNS record (*.domain.com) and a tiny piece of work on the front end, to parse the url & query all necessary data. And, obviously, all data would be stored in a single DB + process on a single server. But of course, we’re not giving our users the subdomains, it’s just an idea (this one’s mind-bending).

Side note, subdomains, probably, wouldn’t work with the siloed approach, mind you. To keep it short, due to the mysterious ways in which SDK fucks with one’s brain.

Now you must be thinking how SaaS is different from anything you’ve created over the years (Days? Months? Decades? Don’t know, don’t care) of hard work. And I can almost see your confusion when I tell you that it’s not different. Everything’s SaaS if you’re brave enough. Gmail? Slack? AWS? Zoom? You pick one, I’ll call it SaaS for you. Don’t we make a great team?

Since we’re past the theory now, let’s build something! Something fairly easy, if you don’t mind, it’s been a tough… fucking year and I’m a bit tired. How does Nest.js + GraphQL + PSQL for backend/Next.js + React for frontend sound to you?

God, these intros are getting longer every time, here’s a capybara for you, take a look, have some rest.

Capybara ❤

And before we write something, lemme just tell you that I rewrote BE for this app at least 3 times & FE 1.5 times (don’t ask, you don’t want to know) with different technologies and the stack I picked for the article is pure fucking love.

Subtitle, I guess?

First things first — let’s set up the backend. Basically, it should be a combination of this article, definitely this one, let’s grab this one as well, and, of course, our beloved ORM. And the best part is that you don’t even have to turn a brain on. I mean it, even if I were braindead, I’d probably still be able to put together some kind of a server, so I won’t be focusing on boilerplate too much.

Naturally, we’ll start with nest new kinda-saas and yarn add @nestjs/apollo @nestjs/graphql graphql apollo-server-express @nestjs/jwt @nestjs/passport passport passport-jwt @prisma/client and maybe some dev dependencies, just for the sake of it yarn add -D prisma @types/passport-jwt

We’re all set! Let’s create a few modules, we’ll definitely need auth, posts, users, and prisma.

Prisma should be the most trouble-free, here’s the way I like to structure it:

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => {
await app.close();
});
}
}
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

Auth is next, it’s virtually the same as in the documentation, here’s what differs:

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req;
}
}
export const CurrentUser = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req.user;
},
);
type User {
id: ID
username: String
email: String
posts: [Post]
}

type Auth {
user: User
access_token: String
}

input CredentialsInput {
username: String!
password: String!
}

input CreateUserInput {
username: String!
email: String!
password: String!
}

type Query {
me: User
}

type Mutation {
login(data: CredentialsInput!): Auth
register(data: CreateUserInput!): Auth
}
@Resolver()
export class AuthResolver {
constructor(
private authService: AuthService,
private usersService: UsersService,
) {}
@Mutation()
async login(@Args('data') data: any) {
return this.authService.login(data);
}
@Mutation()
async register(@Args('data') data: any) {
return this.usersService.createUser(data);
}
@UseGuards(JwtAuthGuard)
@Query()
async me(@CurrentUser() user: any) {
return user;
}
}

Posts:

// posts.resolver.ts
@Resolver()
export class PostsResolver {
constructor(private readonly postsService: PostsService) {}
@UseGuards(JwtAuthGuard)
@Mutation()
async createPost(@Args('data') data: any, @CurrentUser() user: any) {
return this.postsService.createPost({
...data,
author: { connect: { id: user.id } },
});
}

@UseGuards(JwtAuthGuard)
@Mutation()
async updatePost(@Args('data') data: any, @Args('where') where: any) {
return this.postsService.updatePost({
data,
where,
});
}
@UseGuards(JwtAuthGuard)
@Mutation()
async deletePost(@Args('where') where: any) {
return this.postsService.deletePost(where);
}
@Query()
async post(@Args('where') where: any) {
return this.postsService.post(where);
}
@Query()
async posts(@Args('where') where: any) {
return this.postsService.posts({ where });
}
}
// posts.service.ts
@Injectable()
export class PostsService {
constructor(private prisma: PrismaService) {}
async post(
postWhereUniqueInput: Prisma.PostWhereUniqueInput,
): Promise<Post | null> {
return this.prisma.post.findUnique({
where: postWhereUniqueInput,
});
}
async posts(params: {
skip?: number;
take?: number;
cursor?: Prisma.PostWhereUniqueInput;
where?: Prisma.PostWhereInput;
orderBy?: Prisma.PostOrderByWithRelationInput;
}): Promise<Post[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.post.findMany({
skip,
take,
cursor,
where,
orderBy,
});
}
async createPost(data: Prisma.PostCreateInput): Promise<Post> {
return this.prisma.post.create({
data,
});
}
async updatePost(params: {
where: Prisma.PostWhereUniqueInput;
data: Prisma.PostUpdateInput;
}): Promise<Post> {
const { where, data } = params;
return this.prisma.post.update({
data,
where,
});
}
async deletePost(where: Prisma.PostWhereUniqueInput): Promise<Post> {
return this.prisma.post.delete({
where,
});
}
}
# posts.graphql
type Post {
id: ID
title: String
content: String
published: Boolean
author: User
authorId: String
}
input WherePostInput {
id: ID
}
input WherePostsInput {
authorId: String
}
input CreatePostInput {
title: String
content: String
published: Boolean
}
type Query {
post(where: WherePostInput): Post
posts(where: WherePostsInput): [Post]
}
type Mutation {
createPost(data: CreatePostInput): Post
updatePost(data: CreatePostInput, where: WherePostInput): Post
deletePost(where: WherePostInput): Post
}
// posts.module.ts
@Module({
providers: [PostsResolver, PostsService],
imports: [PrismaModule],
})
export class PostsModule {}

As you can see, posts service plainly proxies Prisma, and it works great with our teeny-tiny app, but there will be some issues once we start extending it and our resolver grows disproportionately. Also, you may have noticed an issue with update/delete resolvers. We are not checking whether the user owns the post, which introduces some issues. Namely, some fuckmook may delete the post I published. I see no delight in such situations, so lemme quickly fix the issue:

@Injectable()
export class OwnerGuard implements CanActivate {
constructor(private prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
const args = ctx.getArgs();
const post = await this.prisma.post.findUnique({
where: args.where,
select: { authorId: true },
});
return ctx.getContext().req.user?.id === post.authorId;
}
}

Now this guard can be used the same way as a usual JwtGuard. Neat!

looks about right

Oki, that’s enough, we now can register, login, create posts and have a piece of mind because nobody will delete our posts.

As I already mentioned, we’ll use Next.js for the front end, the process is just as straightforward as for the back end and gorgeously documented. yarn create next-app --typescript and yarn add @apollo/client graphqlleave us exactly where we wanted to be. A few words about Next.js before we dive deeper. Next.js is a powerful and easy to use framework with tons of tools you will, probably, never use. Nevertheless, directory-based routing is love. I’ll skip the login page since it does exactly nothing but sends a single request to our server & stores the token in local storage (No way, I don’t believe it’s a bad practice, don’t lie to me!).

// ./apollo-client.ts
let client: ApolloClient<NormalizedCacheObject>;
const createClient = (): ApolloClient<NormalizedCacheObject> => {
let token;
if (typeof window !== "undefined") {
token = localStorage.getItem("access_token");
}
client = new ApolloClient({
uri: "http://localhost:8000/graphql",
cache: new InMemoryCache(),
headers: { authorization: token ? `Bearer ${token}` : "" },
});
return client;
};

export default createClient();
// ./pages/[username]/[article].tsx
const Article = ({ post }: { post: any }) => {
const router = useRouter();
const { username } = router.query;
return (
<div>
<h1>{post.title}</h1>
<h2>{username}</h2>
<p>{post.content}</p>
</div>
);
};
export default Article;
export async function getStaticPaths(context: GetStaticPathsContext) {
const { data } = await client.query({
query: gql`
query {
users {
username
posts {
id
}
}
}
`,
});
return {
fallback: true,
paths: data.users
.map((user: any) => {
return user.posts.map(({ id }: { id: string }) => ({
params: { username: user.username, article: id },
}));
}).flat(),
};
}
export async function getStaticProps(context: GetStaticPropsContext) {
const { article, username } = context.params as {
username: string;
article: string;
};
const { data } = await client.query({
query: gql`
query Post($article: ID) {
post(where: { id: $article }) {
id
title
content
published
author {
email
username
}
authorId
}
}
`,
variables: {
article,
},
});
return {
props: {
post: data.post,
},
};
}
// ./pages/index.tsx
export default function Home({ users }: { users: any }) {
return (
<div>
<Head>
<title>my gorgeous saas app</title>
</Head>
<ul>
{users.map((user: any, i: number) => {
return (
<li key={i}>
<p>{user.username}</p>
<ul>
{user?.posts?.map(({ id, title }: any, index: number) => {
return (
<li key={index}>
<Link href={`/${user.username}/${id}`}>{title}</Link>
</li>
);
})}
</ul>
</li>
);
})}
</ul>
</div>
);
}
export async function getStaticProps() {
const { data } = await client.query({
query: gql`
query {
users {
username
posts {
id
title
}
}
}
`,
});
return {
props: {
users: data.users,
},
};
}
// ./pages/draft.tsx
const Draft = ({ me, drafts }: any) => {
const router = useRouter();
if (!me && typeof window !== "undefined") {
router.push("/login");
}
const publishDraft = () => {};
const createPost = () => {};
return (
<div>
<button onClick={createPost}>new draft</button>
<ul>
{drafts.map((draft: any) => {
return (
<li key={draft.id}>
<span>{draft.title}</span>{" "}
<span onClick={publishDraft}>publish</span>
</li>
);
})}
</ul>
</div>
);
};
export default Draft;
export async function getStaticProps(context: GetStaticPropsContext) {
try {
const { data: meData } = await client.query({
query: gql`
query {
me {
id
email
username
}
}
`,
});
const { data: draftsData } = await client.query({
query: gql`
query Drafts($article: ID) {
myDrafts {
id
title
content
published
author {
email
username
}
authorId
}
}
`,
});
return {
props: {
me: meData,
drafts: draftsData,
},
};
} catch (err) {}
}

As you can see, Next.js helps dramatically with dynamic routing. That should be it, our SaaS-ish application is ready (almost, I left you a minuscule easter egg).

To sum things up…

SaaS is whatever the fuck you want it to be on the Web. Does our app provide some kind of service? Sure. Is it monetizable? Probably it’s not, but the idea was to learn about SaaS and see some cool technologies along the way if we are lucky, which we are. Till the next time, friend!

--

--