<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Muneeb Ahmad</title>
    <description>The latest articles on DEV Community by Muneeb Ahmad (@muneeb_ahmad_18a2b2f10a70).</description>
    <link>https://dev.to/muneeb_ahmad_18a2b2f10a70</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4110969%2F5a0c5804-f6df-485c-86c6-856f1fcf1ba1.png</url>
      <title>DEV Community: Muneeb Ahmad</title>
      <link>https://dev.to/muneeb_ahmad_18a2b2f10a70</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/muneeb_ahmad_18a2b2f10a70"/>
    <language>en</language>
    <item>
      <title>Building a Job Portal Backend: From Database Design to Deployment</title>
      <dc:creator>Muneeb Ahmad</dc:creator>
      <pubDate>Sun, 18 Oct 2026 17:28:31 +0000</pubDate>
      <link>https://dev.to/muneeb_ahmad_18a2b2f10a70/building-a-job-portal-backend-from-database-design-to-deployment-3e75</link>
      <guid>https://dev.to/muneeb_ahmad_18a2b2f10a70/building-a-job-portal-backend-from-database-design-to-deployment-3e75</guid>
      <description>&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;This project is a multi-tenant job portal built with Node.js, TypeScript, PostgreSQL, Redis, BullMQ, and Docker. It supports three roles: admin, recruiter, and applicans, with strict role-based access control and complete data isolation between companies.&lt;/p&gt;

&lt;p&gt;The system covers the full lifecycle: company onboarding, job posting, applications with profile snapshots, interview scheduling and feedback, admin moderation, async email and resume processing, rate limiting, structured logging, and graceful shutdown. The deployment is containerized with multi-stage Docker builds, Nginx reverse proxy, and a one-off migration service.&lt;/p&gt;

&lt;p&gt;This blog walks through every major decision, like why relational over document, why raw SQL over ORM, why cursor pagination over offset, why files don't belong in the database, how multi-tenant isolation works, and why two tokens are better than one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Why a relational database for a job portal&lt;/li&gt;
&lt;li&gt;Choosing your stack &amp;amp; tools&lt;/li&gt;
&lt;li&gt;Docker Local env&lt;/li&gt;
&lt;li&gt;Configs &amp;amp; Secrets&lt;/li&gt;
&lt;li&gt;Migrations&lt;/li&gt;
&lt;li&gt;Foreign Keys and Constraints&lt;/li&gt;
&lt;li&gt;JSONB attributes&lt;/li&gt;
&lt;li&gt;Request Validation Layer&lt;/li&gt;
&lt;li&gt;Consistent Errors&lt;/li&gt;
&lt;li&gt;Hashing Passwords&lt;/li&gt;
&lt;li&gt;JWT Access Tokens and Refresh Tokens&lt;/li&gt;
&lt;li&gt;Auth Middleware and RBAc&lt;/li&gt;
&lt;li&gt;Multi-tenant Isolation&lt;/li&gt;
&lt;li&gt;Within-company roles&lt;/li&gt;
&lt;li&gt;Inviting Members&lt;/li&gt;
&lt;li&gt;Posting Jobs&lt;/li&gt;
&lt;li&gt;N + 1 Problem&lt;/li&gt;
&lt;li&gt;Cursor Pagination&lt;/li&gt;
&lt;li&gt;Indexing&lt;/li&gt;
&lt;li&gt;Search &amp;amp; Filtering&lt;/li&gt;
&lt;li&gt;Redis Caching&lt;/li&gt;
&lt;li&gt;Cache Invalidation&lt;/li&gt;
&lt;li&gt;Why don't files belong in DB?&lt;/li&gt;
&lt;li&gt;Presigned Uploads&lt;/li&gt;
&lt;li&gt;Profile Snapshots&lt;/li&gt;
&lt;li&gt;Application Transaction&lt;/li&gt;
&lt;li&gt;Idempotent Application&lt;/li&gt;
&lt;li&gt;Stage Pipeline&lt;/li&gt;
&lt;li&gt;Interview Scheduling&lt;/li&gt;
&lt;li&gt;Interview Feedback&lt;/li&gt;
&lt;li&gt;Admin moderation&lt;/li&gt;
&lt;li&gt;Why async&lt;/li&gt;
&lt;li&gt;Queue and Worker setup&lt;/li&gt;
&lt;li&gt;Retries and Idempotency&lt;/li&gt;
&lt;li&gt;Resume Processing&lt;/li&gt;
&lt;li&gt;Scheduled Jobs&lt;/li&gt;
&lt;li&gt;Rate limiting&lt;/li&gt;
&lt;li&gt;Structured Logging&lt;/li&gt;
&lt;li&gt;Health &amp;amp; graceful shutdown&lt;/li&gt;
&lt;li&gt;Deploy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a id="why-a-relational-database-for-a-job-portal"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Why a relational database for a job portal
&lt;/h1&gt;

&lt;p&gt;I had two options for databases, one a relational database like postgresql and second a non-relational/document based database like mongodb.&lt;/p&gt;

&lt;p&gt;For the specific case relational db was the obvious choice,&lt;/p&gt;

&lt;p&gt;Firstly because the data is going to be spread across different entities like user, application, jobs etc, and using a document based approach would require me to strictly hold all the schemas/data values at the application level.&lt;/p&gt;

&lt;p&gt;Secondly, operations like job applications specifically need complete information and for all the intermediate steps to be completed. As the operations following submission require them, operations like profile snapshot, application review etc.&lt;/p&gt;

&lt;p&gt;The document based database does not support important checks or invariants at the database level.&lt;/p&gt;

&lt;p&gt;So choosing postgresql provides me with ACID transactions and ability to enforce relations between data.&lt;/p&gt;

&lt;p&gt;The Question then is: Why not just implement these at application level?&lt;/p&gt;

&lt;p&gt;Answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More code.&lt;/li&gt;
&lt;li&gt;Could be bypassed raising security concerns&lt;/li&gt;
&lt;li&gt;Database should be the main defence wall as this is where we store and retrieve data from.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a id="choosing-your-stack-tools"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Choosing your stack &amp;amp; tools
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Typescript vs javascript
&lt;/h2&gt;

&lt;p&gt;I chose typescript over javascripts as typescript provides type safety during the compile time.&lt;/p&gt;

&lt;h2&gt;
  
  
  SQL vs Query Builder vs ORM
&lt;/h2&gt;

&lt;p&gt;I used raw SQL to get under the hood and not compromise on speed, get complete control of the queries and avoid the n+1 problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zod
&lt;/h2&gt;

&lt;p&gt;The project uses Zod to catch the validation errors at runtime using the already set typescript configurations. No need for separate manual configurations that might introduce inconsistencies as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redis
&lt;/h2&gt;

&lt;p&gt;The project uses redis to cache specific pages and to act as a record for the locally scheduled jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bull MQ
&lt;/h2&gt;

&lt;p&gt;Bull MQ is used for a worker program that runs in the background for tasks like sending emails and processing resumes etc.&lt;/p&gt;

&lt;h2&gt;
  
  
  S3 storage
&lt;/h2&gt;

&lt;p&gt;The project uses S3-compatible storage to store applicant resume pdfs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pino
&lt;/h2&gt;

&lt;p&gt;Pino is used in the project for clear, structured logging for easier debugging.&lt;/p&gt;

&lt;p&gt;&lt;a id="docker-local-env"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Docker Local env
&lt;/h1&gt;

&lt;p&gt;I used docker for the local environment setup, for PostgreSQL and for Redis. That was to keep the versions similar across different devices for consistent development and to avoid it works on my device issue in development. I used docker compose to orchestrate these services. Besides these I used minio as an s3-storage and mailpit to track the emails.&lt;/p&gt;

&lt;p&gt;&lt;a id="configs-secrets"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Configs &amp;amp; Secrets
&lt;/h1&gt;

&lt;p&gt;I used a config file that acts as a centralised place to access all the environment variables related to the backend. No program file accesses the variables directly from the .env files. The config file reads the variables, validates the schema, applies default values and then passes them on.&lt;/p&gt;

&lt;p&gt;Another reason to use it to fail fast right at startup if config is invalid.&lt;/p&gt;

&lt;p&gt;&lt;a id="migrations"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Migrations
&lt;/h1&gt;

&lt;p&gt;I used migrations to set up the database in order without facing the table dependency problems we might face setting the db up manually. For example, in case of a manual setup, we are trying to create an application table but it references a job table or an applicant table which we have not created yet. These sorts of problems are what we avoid with migrations.&lt;/p&gt;

&lt;p&gt;&lt;a id="foreign-keys-and-constraints"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Foreign Keys and Constraints
&lt;/h1&gt;

&lt;p&gt;I specifically used RESTRICT constraint on Primary Keys to not hard delete all the dependent Foreign Keys. As in most cases we want to keep the underlying records like if we are to delete a job we do not want to delete the underlying applicant/application records. I did use CASCADE on the user -&amp;gt; applicant dependencies as when I delete a user I want its record in its specific user type table to be deleted as well&lt;/p&gt;

&lt;p&gt;&lt;a id="jsonb-attributes"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  JSONB attributes
&lt;/h1&gt;

&lt;p&gt;Attributes like different perks a company provides, and questions they ask are arbitrary. So we use JSONB to store their values. We might need to search jobs based on these but due to their arbitrary nature I went with JSONB. And JSONB still provides search capabilities though not as fast as the indexed sql columns.&lt;/p&gt;

&lt;p&gt;&lt;a id="request-validation-layer"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Request Validation Layer
&lt;/h1&gt;

&lt;p&gt;I used a request validation layer that validates the request schema when it hits the server to catch it immediately without rolling it forward. On a match we let it pass and on a violation we throw an error. That prevents any inconveniences we might face in future by preventing the malformed data from reaching the database. The schema is validated using Zod.&lt;/p&gt;

&lt;p&gt;&lt;a id="consistent-errors"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Consistent Errors
&lt;/h1&gt;

&lt;p&gt;I have set a centralised error file that defines the errors with default messages and error code. Different files can import the defined types from here and raise errors with their specific messages. With this we have standard error types and standard mechanisms to throw errors. Since I am using express 4 I use try/catch blocks to catch the errors and pass them as &lt;code&gt;next(err)&lt;/code&gt; for it to reach the handler. (Express 4 does not catch async errors automatically.)&lt;/p&gt;

&lt;p&gt;&lt;a id="hashing-passwords"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Hashing Passwords
&lt;/h1&gt;

&lt;p&gt;We hash the passwords using bcrypt so that brute-forcing is expensive. Bcrypt is intentionally slow to increase the brute-force time and includes a salt per password which makes the precomputed hash attacks less useful. No two identical passwords have a similar hash. And attackers cannot use precomputed tables either.&lt;/p&gt;

&lt;p&gt;&lt;a id="jwt-access-tokens-and-refresh-tokens"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  JWT Access Tokens and Refresh Tokens
&lt;/h1&gt;

&lt;p&gt;I used a pair of tokens, access token and refresh token for the access flow.&lt;/p&gt;

&lt;p&gt;The refresh token is stored in the database, encrypted and has time to live (TTL) of 7 days. While the access token is stored as a signed jwt cookie, with a TTL of 15 mins.&lt;/p&gt;

&lt;p&gt;Now When the access token is expired, the frontend makes a call to the backend to refresh it based on the refresh token hash. It matches the token hash with the hash in the database and on a match returns a new access token.&lt;/p&gt;

&lt;p&gt;Now if it does not match or if it has expired we need to login again and are redirected to the login page.&lt;/p&gt;

&lt;p&gt;Now why use these two the way I am using them?&lt;/p&gt;

&lt;h3&gt;
  
  
  Why sign the access token?
&lt;/h3&gt;

&lt;p&gt;Answer: to prevent session undetected modification of its signed claims. It is signed using HMAC (hash based message authentication code)&lt;/p&gt;

&lt;h3&gt;
  
  
  Why hash the refresh tokens?
&lt;/h3&gt;

&lt;p&gt;To not allow a potential attacker to gain long term login access in case the refresh tokens are compromised. It is a 32-byte random string hashed using SHA-256 which is stored in the database. This prevents an attacker who compromises the database from directly using the stored values as refresh tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens in case of an access token being hacked?
&lt;/h3&gt;

&lt;p&gt;The hacker retains the control for a maximum of 15 minutes then he needs to have a refresh token to generate a new access token. In this time he can use it directly until it expires.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if the refresh token is compromised?
&lt;/h3&gt;

&lt;p&gt;If an attacker attains the raw token, they can use it until it expires. He can now access everything till the token lives.&lt;/p&gt;

&lt;p&gt;After that he needs to login again.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if we know that a hacker has access to our refresh token?
&lt;/h3&gt;

&lt;p&gt;We destroy all the associated refresh tokens from the db. But the hacker can still enjoy the access till the access token lives and that is a max of 15 minutes. That is the price we pay to reduce the db calls for authentication.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not just use a single token?
&lt;/h3&gt;

&lt;p&gt;Using a single token sure seems the easy straightforward option. But this way we would need to call the database on every db call we make that needs authentication. Or have the user login again after small intervals.&lt;/p&gt;

&lt;p&gt;&lt;a id="auth-middleware-and-rbac"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Auth Middleware and RBAc
&lt;/h1&gt;

&lt;p&gt;Auth middleware is added before all the secured routes, and it checks for if the access_token matches the signed hmac, for authentication. Besides this auth middleware, I have also added Role-Based protection by allowing only some roles for specific routes. This is done by getting the user_role from the request and verifying it against the allowed list of roles. With this no applicant can access the routes of an admin.&lt;/p&gt;

&lt;p&gt;Now the problem is that the request could be modified or lets say it could be tampered with to change the roles.&lt;/p&gt;

&lt;p&gt;That issue is resolved in the auth middleware. After the user is authenticated and the token is generated there we add the user id and user role in the request and then that updated request moves to the next part of requireRole or authorization.&lt;/p&gt;

&lt;p&gt;&lt;a id="multi-tenant-isolation"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Multi-tenant Isolation
&lt;/h1&gt;

&lt;p&gt;Multi-tenant isolation is achieved in a similar way. The backend does not expect the different ids, user id or company ids etc to be passed by the frontend as request headers or params. What the backend does is query the database for different ids against their authenticated information. For example if an applicant wants to search its applications, the backend first verifies the authentication status then authorization and then the database is queried using the applicants-id referencing all the related applications. So all the ids and stuff are managed in the backend providing complete isolation to tenants.&lt;/p&gt;

&lt;p&gt;This concept of avoiding using params and querying the backend with specific scope of applicant-ids etc is called scoping queries.&lt;/p&gt;

&lt;p&gt;This is also called IDOR prevention which means not exposing the database ids to unauthorized users.&lt;/p&gt;

&lt;p&gt;I have also used clear ownership checks, directly using databases to verify the ownership. For example there is a method &lt;code&gt;assertJobOwnership&lt;/code&gt;, that compares the recruiter’s company id with the jobs company id and if it does not match the recruiter is faced with a not found error. This pattern hides details that the recruiter is not authorized to see.&lt;/p&gt;

&lt;p&gt;&lt;a id="within-company-roles"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Within-company roles
&lt;/h1&gt;

&lt;p&gt;When a user is recruited or invited to the company he is assigned one of the 4 company roles, owner (initial), recruiter, hiring manager and hr manager. The main distinction between them is who can manage other members and that is the owner. The rest of the permissions are shared like managing jobs, managing applications etc.&lt;/p&gt;

&lt;p&gt;&lt;a id="inviting-members"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Inviting Members
&lt;/h1&gt;

&lt;p&gt;The owner can invite new members by their email and a role. When the invitation function is call, the backend first checks for if the company exists. Then it checks for if the inviter is authorized to invite. Then we check if the invitee is already a member of the company. Then we check if there exist any pending invitations already. After all these checks we generate an invitation.&lt;/p&gt;

&lt;p&gt;The invitation consists of 32 random bytes hashed using sha-256 to prevent brute-force attempts. This is then stored in the db and the raw token is sent to the user email through brevo. The email task is scheduled in redis and BullMQ workers complete it asynchronously. The user clicks the link sent with the token as params. The token is then compared with the one in db. The token is then validated for expiry etc. Then if the user already has an account with the platform he gets the role. And if it's a new user he is redirected to add password etc and is logged in the same way with the company role.&lt;/p&gt;

&lt;p&gt;&lt;a id="posting-jobs"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Posting Jobs
&lt;/h1&gt;

&lt;p&gt;When a job posting request hits the backend, first the user is checked for authentication, then for authorization if he’s authorized as a recruiter role. Then the company specific role is verified (for this case every company role is allowed to manage jobs) and then the request schema is validated. And then the job is posted, the details being saved in the database.&lt;/p&gt;

&lt;p&gt;&lt;a id="n-1-problem"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  N + 1 Problem
&lt;/h1&gt;

&lt;p&gt;The N+1 problem is when you make one query to get a list of records, then make N additional queries, one for each record to fetch related data. In my public job board, I initially fetched all open jobs, then looped through each job to fetch its company name. For 20 jobs, that was 21 queries.&lt;/p&gt;

&lt;p&gt;I fixed it by joining the jobs and companies tables in a single query. The job board now loads in one query instead of 1 + N, regardless of how many jobs are returned. In cases where join was not possible I made 2 queries one to fetch the record ids and the second query to fetch the records using &lt;code&gt;ANY($1)&lt;/code&gt; reducing the N + 1 count to just 2. This helps us from overwhelming the db client.&lt;/p&gt;

&lt;p&gt;&lt;a id="cursor-pagination"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Cursor Pagination
&lt;/h1&gt;

&lt;p&gt;This project uses cursor pagination chosen after comparison with the offset pagination.&lt;/p&gt;

&lt;p&gt;Cursor pagination uses a unique identifier which is a combination of different attributes that define the sort order. The attributes I am using are date and id. The composite of these two is then encoded with base64 and is passed to the user. Based on this encoded base64 string the next query is made, where first this is decoded and then based on this the query is made. The fetch query is told to fetch one extra record than what is asked for by the user, and then the base64 string is made from the attributes of that extra record. This extra record is a lookahead and is not returned to the user. If the length of the query response is less than expected it means we have no other records left.&lt;/p&gt;

&lt;p&gt;Offset pagination simply uses the page size, an offset and a page number based on these two the database query is made and the results are displayed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why choose cursor pagination over offset pagination?
&lt;/h3&gt;

&lt;p&gt;In applications like the job-portal systems, new objects/jobs are being added and deleted all the time. So when a user is searching through jobs and a new job is added or deleted we want to make sure that the user does not miss any jobs or have to see the same job twice. These problems exist with offset pagination. As when the user lets say move to page two and during that a new job is created the user will now see the last job from the previous page as its first job. In case of deletion if a job from page 1 is deleted during the transition to page 2 we may miss the first job from page 2 as based on the index and order it now belongs to page 1.&lt;/p&gt;

&lt;p&gt;As cursor pagination uses attributes like date and id, these experiences for the mentioned reasons can not happen. We simply ask the database to provide us responses from a specific id of a specific object and not an index that could change for different reasons.&lt;/p&gt;

&lt;p&gt;Another reason is that cursor pagination is faster and has a consistent speed at scale compared to offset pagination that slows down. Offset pagination slows down as the database has to go through all the records just to fetch the records for a specific page. Like on page 20 with offset 20 it has to go through 400 records just to return the last 20 records. It's evident how bad that would be at scale. We could use high offset value to reduce frequent searches but it would still cause similar problems at scale.&lt;/p&gt;

&lt;p&gt;Cursor pagination on the other hand could even be sped up if index the attributes used for querying the records which for us include date and id (though id is already indexed as its a PK).&lt;/p&gt;

&lt;h3&gt;
  
  
  What is wrong with cursor pagination?
&lt;/h3&gt;

&lt;p&gt;With cursor pagination if the encoding string is lost we can not resume our search or scroll from the same point we will have to start from the very first record. Simply put the user cannot jump between pages. But that is not a problem for a job portal where users scroll through the jobs and do not jump pages.&lt;/p&gt;

&lt;p&gt;Now for cursor pagination there could be a question for why not just use id or just use date for sorting. The answer is if we were to use just the date, there would be instances where multiple jobs could have the same date of insertion/creation. Or if we just used id the sort/order would be stable but would not be newest first which is the natural expectation from the user. So for it to be newest first we need to sort using date and to avoid the tie breakers we need to use the ids.&lt;/p&gt;

&lt;p&gt;&lt;a id="indexing"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Indexing
&lt;/h1&gt;

&lt;p&gt;I have indexed different attributes from different tables to make frequent queries faster. I will discuss here what those attributes are and why indexing them was a good option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idx_companies_status
&lt;/h3&gt;

&lt;p&gt;companies are filtered based on their status and the records returned are with status verified. Any other status is not considered. Idx_companies_status supports queries that filter companies by status, such as listing verified companies or finding companies in a particular state. For other queries its usefulness depends on the user of relevant indexes.&lt;/p&gt;

&lt;h3&gt;
  
  
  idx_invitations_expires_at, idx_refresh_tokens_expires_at
&lt;/h3&gt;

&lt;p&gt;The job cleanup cron jobs use invitations expires_at and refresh_tokens expires_at attributes to delete the expired jobs, so they are also accessed quite frequently. And indexing them really helps the worker to clean these up faster (as there could be a lot of expired invitations and refresh_tokens at scale).&lt;/p&gt;

&lt;h3&gt;
  
  
  Idx_invitations_token_hash
&lt;/h3&gt;

&lt;p&gt;invitations _ token_hash is also accessed frequently, it is accessed whenever a new member is added. So querying from the invitation token to match the token_hash is going to be slower to speed it up. I have indexed the token_hash as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idx_jobs_search
&lt;/h3&gt;

&lt;p&gt;The jobs search idx is based on the search_vector that is gonna be needed at every search i.e. frequently needed. As the search vector is going to be a huge collection indexing them does increase the size but it also makes the search faster at scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  idx_jobs_status_created
&lt;/h3&gt;

&lt;p&gt;the jobs status_created idx is a composite idx based on two attributes, the jobs status and the time it is created_at. The jobs board filters the jobs based on status like only the public jobs could be listed at the public board (that’s why it is on the left i.e primary filter) then the jobs are filtered based on how recent they are (new jobs first). A public job board is the most accessed service of any job portal system. That's why the use of composite index to speed up the reads. It however introduces a problem and that is the writes get slower at scale. But slower job writes are not really a problem for the job board, as writes are less frequent than reads.&lt;/p&gt;

&lt;h3&gt;
  
  
  Foreign key indexes
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;idx_refresh_tokens_user_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;idx_email_verifications_user_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;idx_recruiters_company_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;idx_recruiters_user_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Idx_jobs_company_id&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the foreign key indexes for the most frequent joins. I picked the most foreign keys needed in the most frequent joins for indexing.&lt;/p&gt;

&lt;p&gt;&lt;a id="search-filtering"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Search &amp;amp; Filtering
&lt;/h1&gt;

&lt;p&gt;Applications, Jobs, Companies, and Members for different user dashboards are filtered based on different attributes. For example companies in admin panel are filtered based on there status, jobs are filtered in the recruiter panel based on if there status draft, open or private and similar filters hold for other options too. These attributes used for filtering are indexed for faster queries as mentioned before.&lt;/p&gt;

&lt;p&gt;For searching (specifically used for jobs) I have used a &lt;code&gt;search_vector&lt;/code&gt;/&lt;code&gt;ts_vector&lt;/code&gt; which is created using a trigger on job insert. It breaks the job title and descriptions tokenized into a vector. It uses GIN index for faster response. The problem with this is that because of the index it uses extra storage, for a small change we need to rebuild all ts_vectors, the write performance is slower.&lt;/p&gt;

&lt;p&gt;It is preferred over the other approach of inline computation where the PSQL would have to parse every row in the table during execution.&lt;/p&gt;

&lt;p&gt;&lt;a id="redis-caching"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Redis Caching
&lt;/h1&gt;

&lt;p&gt;The public job portal, as would have the most requests, I have implemented cache storage for that page. Besides this, async jobs like, sending emails for different purposes i.e. member invitation, otp message etc. and for cron jobs like refresh_token cleanup etc. And rate limiting also uses redis caching to track different client requests and block those that are potential attacks.&lt;/p&gt;

&lt;p&gt;&lt;a id="cache-invalidation"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Cache Invalidation
&lt;/h1&gt;

&lt;p&gt;Caches need to be invalidated, because the data is changed frequently so it could get stale. Like the new jobs could have been added, old ones could have been deleted etc. So what I did is add a TTL of 60 seconds to the cache so after every 60 seconds the cache expired and a new request to the db was made for updated data.&lt;/p&gt;

&lt;p&gt;Then in scenarios where user updates the records may be add or delete records the redis cache is invalidated and then updated with data from the database. In scenarios like these when a change is made doing a two phased commit can introduce inconsistencies. Like what if the redis commit fails and what if the db commit fails? If redis commit fails the specific user would have stale cache for the max time duration of TTL of the cache. If the db commit fails all the users no one sees the change, the cache still holds old data and the db never gets the update. That's worse than before as the data is inconsistent between what the user expects and what’s stored.&lt;/p&gt;

&lt;p&gt;&lt;a id="why-dont-files-belong-in-db"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Why don't files belong in DB?
&lt;/h1&gt;

&lt;p&gt;Large files do not belong in DB because of three costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage cost
&lt;/h2&gt;

&lt;p&gt;relational db costs around 0.18 to 0.25 usd per gb per month while S3 storages like amazon costs around 0.023 about 10x cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Throughput cost
&lt;/h2&gt;

&lt;p&gt;PSQL serves data over a connection pool. Every pool is a finite source capped at around 20-100 connections. A database query that returns a resume, must hold the connection during the duration of the transfer that could be a few seconds. If the file size is 500kb and the throughput is 5 Mbps it would take 0.8 seconds for the complete transfer and during that time the connection can not serve any other query. S3 based storages on the other hand are built specifically for large file transfers and they do not hold the connection either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational Cost
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Backups
&lt;/h3&gt;

&lt;p&gt;storing resumes in PSQL results in increased sizes for the backups, backing up would take hours.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migrations
&lt;/h3&gt;

&lt;p&gt;20gbs of Blob-heavy tables would take hours migrating.&lt;/p&gt;

&lt;h3&gt;
  
  
  Replicas
&lt;/h3&gt;

&lt;p&gt;WAL replication sends all the changes to replicas. Binary writes are replicated byte for byte adding huge pressure on bandwidth.&lt;/p&gt;

&lt;p&gt;&lt;a id="presigned-uploads"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Presigned Uploads
&lt;/h1&gt;

&lt;p&gt;Presigned uploads are used in s3-uploads without revealing the server side secrets to the client. The server sends a request to the S3 api along with its S3-credentials. The server returns a presigned-url that is sent to the client. The client then uses that presigned url to upload the resume.&lt;/p&gt;

&lt;p&gt;&lt;a id="profile-snapshots"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Profile Snapshots
&lt;/h1&gt;

&lt;p&gt;Profile snapshots are an important part of a job application. Profile snapshots are snapshots of applicant details taken when the applicant applies for a job. The snapshot holds the historical record of the user profile which allows the recruiters to judge the applicant profile based on how it was at the time of application.&lt;/p&gt;

&lt;p&gt;&lt;a id="application-transaction"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Application Transaction
&lt;/h1&gt;

&lt;p&gt;The need for application transactions arises from the problems we could face from the pre-signed url. For example lets say the query to store the presigned url (to later retrieve the resume) is completed but the update fails. What will happen now is that we have a reference url but we don’t have any file stored against it. To make these queries atomic and to avoid these inconsistencies we wrap these up in a transaction. The psql transactions run on a single connection as multiple connections does not guarantee atomicity. It rolls back on any error to avoid the inconsistencies.&lt;/p&gt;

&lt;p&gt;&lt;a id="idempotent-application"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Idempotent Application
&lt;/h1&gt;

&lt;p&gt;The job applications are idempotent. Idempotent means that if an applicant applies to the same job multiple times the state of the db is gonna stay the same to what it would be applying once. The applicant is allowed to apply multiple times but only the first application would be considered and the user would just get the success message every time, regardless the nth count it is. This is to maintain a state that is expected throughout the system, like no multiple applications per applicant per job. The applicant always gets the same success message, regardless of whether the application was new or a duplicate&lt;/p&gt;

&lt;p&gt;&lt;a id="stage-pipeline"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Stage Pipeline
&lt;/h1&gt;

&lt;p&gt;Each application goes through a stage pipeline. The pipeline has stages: &lt;code&gt;screening&lt;/code&gt;, &lt;code&gt;interview&lt;/code&gt;, &lt;code&gt;final_interview&lt;/code&gt;, &lt;code&gt;offer&lt;/code&gt;, &lt;code&gt;hired&lt;/code&gt;, &lt;code&gt;rejected&lt;/code&gt;. The transition rules are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The targetStage must be a valid stage name in stage order&lt;/li&gt;
&lt;li&gt;The current stage must not be hired or rejected.&lt;/li&gt;
&lt;li&gt;The targetStage index must be greater than the current stage index (forward only)&lt;/li&gt;
&lt;li&gt;Exception: rejected can be reached from any non-terminal stage (it is a special terminal that can be entered from any point)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why are backward transitions blocked?
&lt;/h2&gt;

&lt;p&gt;They are blocked because moving an application backward from interview to screening would cause confusion and is not consistent with the natural flow.&lt;/p&gt;

&lt;p&gt;The status field provides another dimension to the application with ‘withdraw’. The applicant can withdraw his application and when that happens the stage cannot be changed by the recruiter as there is no point in doing so.&lt;/p&gt;

&lt;p&gt;&lt;a id="interview-scheduling"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Interview Scheduling
&lt;/h1&gt;

&lt;p&gt;An interview scheduling feature is added where the recruiter can schedule an interview with a specific applicant, against an application. The interview table references the application table. When an interview is scheduled the stage is implicitly transitioned to interview. This transition would be expected by the recruiter most of the time and the absence of this feature could lead to inconsistencies where an interview is scheduled for an application that is staged at screening. An email is delivered to the applicant informing him of the meeting through the smtp server. The email transfer is asynchronous and is carried out by the BullMQ worker. On failure it automatically retries and keeps retrying.&lt;/p&gt;

&lt;p&gt;&lt;a id="interview-feedback"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Interview Feedback
&lt;/h1&gt;

&lt;p&gt;The recruiter can add the interview feedback and then can either move the candidate to the next stage which would be the logical next stage or it could reject the application.&lt;/p&gt;

&lt;p&gt;The interview feedback is immutable which means once a feedback is given it cannot be overwritten as that could cause a data integrity problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if the recruiter makes some sort of mistake with the feedback?
&lt;/h2&gt;

&lt;p&gt;The project does not solve this problem but a solution to it could be adding a small time window in which the recruiter is allowed to edit the feedback. How we can implement this is by using an attribute &lt;code&gt;feedback_created_time&lt;/code&gt; and allow the edits when the difference between feedback_created_time and current time is less than the window we have decided on.&lt;/p&gt;

&lt;p&gt;&lt;a id="admin-moderation"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Admin moderation
&lt;/h1&gt;

&lt;p&gt;The admin gets an overall view of the system entities. It has access to all the companies, jobs and users.&lt;/p&gt;

&lt;p&gt;When a user registers a company the status for that company is set to pending. That status is then verified by the admin and then it can post jobs and do hirings. If a company violates some standards, the admin can suspend that too. A suspended company can be reverified after investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens to the company jobs when it is suspended?
&lt;/h2&gt;

&lt;p&gt;If a company is found to be engaged in fraudulent activities and the admin wants to suspend it, it's only obvious that the admin doesn’t want any more applications to the company jobs so the backend suspends all the company jobs in the backend.&lt;/p&gt;

&lt;p&gt;The admin can view all the jobs and can target specific jobs changing their status, closing them.&lt;/p&gt;

&lt;p&gt;The admin can also view all the users and can suspend specific users and then also verify them again. Suspending a user deletes or suspends all the refresh tokens associated with him. But the access token remains valid until it expires (max 15 minutes). After that expires the user loses all his access.&lt;/p&gt;

&lt;p&gt;An admin can mistakenly suspend itself and lock itself out. To prevent that an id check is added where the admin user_id from the jwt token (added at the time of login) is matched against the user_id we are trying to suspend, and the operation only proceeds if the two do not match.&lt;/p&gt;

&lt;p&gt;&lt;a id="why-async"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Why async
&lt;/h1&gt;

&lt;p&gt;Slow operations should not block the HTTP response. Email delivery, pdf processing and api responses can take 100 of milliseconds or more. Making the user wait for them increases latency and the user perceives the application as slower. And if the slow operation fails the user gets an error unrelated to what he submitted.&lt;/p&gt;

&lt;p&gt;A job queue is durable; an in-process event emitter is not. Node.js EventEmitter fires and forgets inside the same process. If the process crashes between emitting the event and handling it, the event is lost with no record of it ever happening. A job queue writes the job to Redis before the HTTP response returns. The job survives a worker crash or a server restart.&lt;/p&gt;

&lt;p&gt;BullMQ stores each job as a Redis key. When you call &lt;code&gt;queue.add('job-name', payload)&lt;/code&gt;, BullMQ serialises the payload to JSON and writes it into a Redis sorted set. Workers poll that sorted set and atomically move jobs to a "processing" state. If the worker crashes mid-job, the job moves back to waiting after a configurable lock expiry.&lt;/p&gt;

&lt;p&gt;At-least-once delivery means jobs can run more than once. BullMQ guarantees that a job will eventually be processed at least once. It does not guarantee exactly once nor does it guarantee the success of a job. If a worker completes the job but crashes before acknowledging it, the job may run again. Workers must be idempotent running the job twice should produce the same outcome as running it once.&lt;/p&gt;

&lt;p&gt;Three things should happen before returning HTTP 200 (or 201). Validate the input. Write to the database. Enqueue the background job. Everything else, sending the email, processing the file, calling the third-party API etc. happens after the response is returned. The user gets a fast response and the side effect happens within seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Queue vs event bus
&lt;/h2&gt;

&lt;p&gt;The question is "what happens if no worker is running?" A job queue holds the job in Redis until a worker picks it up. The job can wait hours or days. An event bus fires the event immediately. If no worker is listening, the event is gone. For this system emails, file processing, a queue is the right choice because worker downtime must not cause data loss.&lt;/p&gt;

&lt;p&gt;Concurrency is explicit in BullMQ. When you create a Worker, you set a concurrency number. &lt;code&gt;{ concurrency: 5 }&lt;/code&gt; means the worker processes up to 5 jobs simultaneously. Default is 1 (sequential). This is different from the HTTP server, where every request is handled concurrently by Node.js's event loop.&lt;/p&gt;

&lt;p&gt;&lt;a id="queue-and-worker-setup"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Queue and Worker setup
&lt;/h1&gt;

&lt;p&gt;BullMQ is the queue this project uses to queue the jobs in redis and the worker reads those jobs and processes them. Both BullMQ and the worker have separate redis connections that is because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;BullMQ uses blocking Redis commands and Lua scripts that require dedicated, non-shared connections to avoid blocking other operations. For that purpose the connection is not shared between the queue and the worker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The job request is made by calling the job-key stored as a key value pair in BullMQ, from there the job is stored in the redis. A worker retrieves the job, processes it and after completion marks it as done. If a job is failed the worker retries unless the job is completed.&lt;/p&gt;

&lt;p&gt;If the job fails for some reason the worker retries it again and again. To handle this we add a specific case where we implement the logic of preventing the retries to go on forever. We add a limit to the number of retries.&lt;/p&gt;

&lt;p&gt;If the job succeeds but the worker fails to register its completion the worker retries the job later and then updates the status. The jobs are idempotent so the state says the same no matter if the job was executed once or more than once.&lt;/p&gt;

&lt;p&gt;&lt;a id="retries-and-idempotency"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Retries and Idempotency
&lt;/h1&gt;

&lt;p&gt;The default job options from BullMQ uses attributes like &lt;code&gt;attempts&lt;/code&gt; to specify the number of retry attempts to make before stopping and &lt;code&gt;backoff&lt;/code&gt; to specify the delay between each retry and how that wait increases after each failure like sequential increase, exponential increase etc. The project uses exponential increase.&lt;/p&gt;

&lt;p&gt;Retries are important because if a job fails without completion it becomes a permanent data loss unless a retry attempt is made.Backoff is important because without it the worker immediately retries the failed job flooding the already struggling service.&lt;/p&gt;

&lt;p&gt;The job is safe to retry because the worst case is a duplicate email, it is annoying, but not data corruption. True idempotency would require deduplication.&lt;/p&gt;

&lt;p&gt;For other jobs we have explicitly handled the conflict at database level using &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;. So when a conflict happens the database just returns a successful response without doing anything.&lt;/p&gt;

&lt;p&gt;&lt;a id="resume-processing"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Resume Processing
&lt;/h1&gt;

&lt;p&gt;The user apploads a resume to the frontend. The request to the backend is made with the file. The backend requests the s3 server which responds with a presigned url. This url is then used to upload the resume. After completion a job is run to parse resume and count words and update the database with the word count. The job is enqueued by BullMQ and picked up by the worker.&lt;/p&gt;

&lt;p&gt;We await the s3 stream chunks and collect them in a chunks array which is then concatted to a buffer.The buffer is passed to the pdf parse after which it is available for garbage collection. While being parsed a resume occupies more space then its initial size. With a 100mb resume the worker could experience high memory pressure, slow processing and risk of OOM errors if multiple large files are processed concurrently, So a size limit is applied rejecting the oversized resumes.&lt;/p&gt;

&lt;p&gt;&lt;a id="scheduled-jobs"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Scheduled Jobs
&lt;/h1&gt;

&lt;p&gt;Repeatable jobs are scheduled as cron jobs with defined schedules. For example this project schedules jobs to clean up expired refresh tokens and OTPs and also schedules a job to send an email digest to the recruiters.&lt;/p&gt;

&lt;p&gt;&lt;a id="rate-limiting"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Rate limiting
&lt;/h1&gt;

&lt;p&gt;This project uses express rate limiting and redis stores the request counts for consistent rate limiting across instances. Each request is counted in redis. if the limit is reached the request is rejected. All of this happens in the middleware. The middleware supports both a global limit and route-specific limits for example, rate limiting for auth.&lt;/p&gt;

&lt;p&gt;Rate limiting prevents the project from abuse. The attackers cannot bruteforce a login because rate limiting increases the cost.&lt;/p&gt;

&lt;p&gt;The reason we are specifically using redis and not the servers storage is because if a server fails the counter is set to 0. And for multiple servers the counter would be split across servers, making the limit unreliable. With redis the count is the same for all the servers.&lt;/p&gt;

&lt;p&gt;&lt;a id="structured-logging"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Structured Logging
&lt;/h1&gt;

&lt;p&gt;This project uses pino for structured logging. Each log has its own request id. Structured logging writes a JSON object per line. Log aggregators index these fields. They are then queried with the aggregator's UI.&lt;/p&gt;

&lt;p&gt;Using console.log produces a plain string. To find all login events for a specific user, you would need grep. grep on a log file works fine on one server. It breaks when you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multiple servers (logs are on different machines)&lt;/li&gt;
&lt;li&gt;High volume (millions of log lines per day)&lt;/li&gt;
&lt;li&gt;Complex queries ("all 500 errors in the last hour on the /api/auth/login route")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a id="health-graceful-shutdown"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Health &amp;amp; graceful shutdown
&lt;/h1&gt;

&lt;p&gt;The project has two health points: &lt;code&gt;/ready&lt;/code&gt; to check if the server is ready and &lt;code&gt;/health&lt;/code&gt; to check if all the services are healthy, it checks for the database and the redis services for if they are working correctly or not. If everything is good the endpoint returns 200 else 503 the internal server error.&lt;/p&gt;

&lt;p&gt;Graceful shutdown is implemented with which on SIGTERM the server gracefully shutdowns the services and blocks new requests and also waits for requests being processed to be completed. The worker is also gracefully shutdown, it waits for the ongoing jobs to complete and then shuts down.&lt;/p&gt;

&lt;p&gt;&lt;a id="deploy"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Deploy
&lt;/h1&gt;

&lt;p&gt;The project uses docker images for the api, the worker and the one time migration task. These images use multi-stage build to reduce the image size and also reduce the attack surface by not including the source code.&lt;/p&gt;

&lt;p&gt;Docker compose starts the full stack: PSQL, Redis, MinIO(s3-storage), the API, the worker and nginx as a reverse proxy. The migrate service runs before the API starts. It requires the PSQL to be healthy. The API and worker depends on the success of migration, that is to avoid starting with a missing schema. Environment secrets are injected on the run and are not included in the image. THe worker runs as a separate process so API failures do not affect jobs processing. Health checks on the services helps docker restart them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on live deployment
&lt;/h2&gt;

&lt;p&gt;The project is containerized and ready to deploy, but I haven't hosted it live. Render's free tier does not support background workers, and the worker is important to this system. Without it, emails and resume processing do not run. Paying $7/month for a portfolio project wasn't suitable for me as a student, so the deployment configs are included in the repo and the system can be brought up locally or on any paid host with docker compose -f docker-compose.prod.yml --profile migrate up -d --build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This project took me from basic CRUD logic to building a full-fledged multi-tenant system. It helped me understand how production systems are built and held together. It helped me understand and implement the concepts of idempotency, preventing IDOR, dual-token authentication, RBAC, and more.&lt;/p&gt;

&lt;p&gt;Every decision was a trade-off between different options. I learned that no single choice fits every system.&lt;/p&gt;

&lt;p&gt;This project serves as proof of my backend design skills. It shows I can build a multi-tenant system with async processing and clean deployment pipelines. The repo is public, the deployment configs are included, and it runs locally or on any host with Docker Compose.&lt;/p&gt;

&lt;p&gt;If you have questions or feedback, reach out at: &lt;a href="https://dev.toLinkedin"&gt;https://www.linkedin.com/in/muneeb-ahmad-se/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/Muneeb-Ahmad-404/Talent-Hunt" rel="noopener noreferrer"&gt;github.com/Muneeb-Ahmad-404/Talent-Hunt&lt;/a&gt;&lt;/p&gt;

</description>
      <category>backenddevelopment</category>
      <category>node</category>
      <category>systemdesign</category>
      <category>postgres</category>
    </item>
    <item>
      <title>My GSoC Journey with InVesalius: Bringing PACS Integration to InVesalius</title>
      <dc:creator>Muneeb Ahmad</dc:creator>
      <pubDate>Sat, 05 Sep 2026 10:45:34 +0000</pubDate>
      <link>https://dev.to/muneeb_ahmad_18a2b2f10a70/my-gsoc-journey-with-invesalius-bringing-pacs-integration-to-invesalius-3m39</link>
      <guid>https://dev.to/muneeb_ahmad_18a2b2f10a70/my-gsoc-journey-with-invesalius-bringing-pacs-integration-to-invesalius-3m39</guid>
      <description>&lt;h2&gt;
  
  
  My GSoC Journey with InVesalius: Bringing PACS Integration to InVesalius
&lt;/h2&gt;

&lt;p&gt;For the past three months I have been contributing at Invesalius as a GSOC contributor. It has been quite a fun summer and now that I have finally completed the project I wanted to share a sneak peak of what I did and learnt and about the org.&lt;/p&gt;

&lt;h2&gt;
  
  
  InVesalius
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dry4k3yvy2vei9cbfob.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dry4k3yvy2vei9cbfob.png" alt="Invesalisus" width="800" height="490"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A well-established open-source medical imaging software developed in Brazil. It enables the generation of physical models through 3D printing using two-dimensional (2D) images acquired from CT or MRI. By converting these 2D images into 3D anatomical representations, healthcare professionals can utilize the resulting models for various medical purposes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Invesalius did not support remote connections with hospital servers (PACS) so to exchange data external tools were required, which is an overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;Use libraries like pynetdicom and pydicom to build methods that support querying Hospital servers (PACS) for image search and retrieval over the network, without the manual effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick introduction to PACS and DICOM
&lt;/h2&gt;

&lt;p&gt;PACS i.e. Picture Archiving and Communication System does exactly what it sounds like, it helps us manage pictures by communicating with the systems.&lt;/p&gt;

&lt;p&gt;Which systems you say? DICOM systems.&lt;/p&gt;

&lt;p&gt;Now what are DICOMs?&lt;/p&gt;

&lt;p&gt;DICOM is the standard used for storing, communicating, querying and retrieving medical images and related information. DICOM servers are the systems that implement this standard and host/manage these images, acting as a centralized picture archive/storage for a hospital. Some examples are Orthanc and dcm4chee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk8yf0wg5ejyv9260vmun.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk8yf0wg5ejyv9260vmun.png" alt="Architecture" width="739" height="560"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the DICOM Operations Fit Together
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxytoznrjsgdwe5p6v4os.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxytoznrjsgdwe5p6v4os.png" alt="Pacs Flow" width="621" height="903"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The basic flow is:&lt;/p&gt;

&lt;p&gt;Connectivity → Search → Retrieval → Local Storage&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;C-ECHO: to check if the server is connected&lt;/li&gt;
&lt;li&gt;C-FIND: to search patients across available nodes/servers&lt;/li&gt;
&lt;li&gt;C-MOVE: to retrieve/download images&lt;/li&gt;
&lt;li&gt;C-GET: to retrieve/download images, used as a fallback&lt;/li&gt;
&lt;li&gt;C-STORE: to store retrieved images in a pre-specified folder&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Patient → Study → Series → Image
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Felv28sw1fl5liq87c8hh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Felv28sw1fl5liq87c8hh.png" alt="DICOM Data Hierarchy" width="289" height="318"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The images in a DICOM server are organized hierarchically:&lt;br&gt;
Patient → Study → Series → Image&lt;br&gt;
This also allowed the user to download patient images at different hierarchy levels like patient, study, series.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Did
&lt;/h2&gt;

&lt;h3&gt;
  
  
  DICOM Networking
&lt;/h3&gt;

&lt;p&gt;Implemented querying methods like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;c-echo: to check if the server is connected&lt;/li&gt;
&lt;li&gt;c-find: to search patients across available nodes/servers&lt;/li&gt;
&lt;li&gt;c-move: to retrieve/download images&lt;/li&gt;
&lt;li&gt;c-get: to retrieve/download images - used as a fallback&lt;/li&gt;
&lt;li&gt;c-store: to store retrieved images in a pre-specified folder&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  User Interface
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4wzovjeer7r0se9c8wjp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4wzovjeer7r0se9c8wjp.png" alt="PACS UI" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Implemented ui for user interaction that includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search bar and associated dialogs for searching&lt;/li&gt;
&lt;li&gt;UI to manage nodes/servers for image retrieval.&lt;/li&gt;
&lt;li&gt;Option to download patient images at different hierarchy levels like: patient, study, series.&lt;/li&gt;
&lt;li&gt;Cancellation option to stop download, and clean up the directory.&lt;/li&gt;
&lt;li&gt;Preferences dialog for network options to set download directory, choose download method, and application configuration.&lt;/li&gt;
&lt;li&gt;Checking if the specific images are already downloaded to prevent duplicates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4yz0hvj88hw3wub7fprk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4yz0hvj88hw3wub7fprk.png" alt="Preference Dialog" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Backend best practices and optimizations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Graceful error handling: if c-move fails automatically falls back to c-get.&lt;/li&gt;
&lt;li&gt;Type hinting for type safety.&lt;/li&gt;
&lt;li&gt;Clear logging for easier debugging.&lt;/li&gt;
&lt;li&gt;Query methods wrapped with async methods to prevent ui-freeze.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Engineering Challenges
&lt;/h2&gt;

&lt;p&gt;This is the section I would give the most attention to. These are actual problems you encountered rather than simply features you implemented.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Falling back from C-MOVE to C-GET
&lt;/h3&gt;

&lt;p&gt;One of the challenges was to use c-get as fallback if c-move was to fail.&lt;br&gt;
I had to think of a creative way, so I came up with a less creative simple solution and that was to handle it as an error and also support the user to choose his preffered method.&lt;br&gt;
So what I did was to create one orchestrator for both methods and handle error gracefully like if the choice was cmove and an error occurs then move to orchestrate c-get, pretty simple i know but i was happy when i came up with it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkehu7jre3xnuojek8ypt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkehu7jre3xnuojek8ypt.png" alt="Fall Back" width="345" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Cancellation, Cleanup and Progress Tracking
&lt;/h3&gt;

&lt;p&gt;Second challenge would be to handle the actual cancelling the c-move, cleaning the downloaded ones and not fallback to c-get and also deal with the download dialog without ui freeze.&lt;br&gt;
I used wx.Callafter functions for this, raised error “CANCELLED”.. and tracked at orchestrator, removing all the downloaded images.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg0bqwtsa6tb2wguk8pwu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg0bqwtsa6tb2wguk8pwu.png" alt="Cancellation Flow" width="799" height="527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2m24cxhe6reschbs9wig.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2m24cxhe6reschbs9wig.png" alt="Cancellation Dialog" width="702" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The main problem was the progress dialog.&lt;br&gt;
I used initially a progress dialog with exact downloaded count and boy that was tough to continually update it after each image, and then there was another problem was that orthanc sent one image per response so tracking was easier, but when i tried dcm4chee it sent all the images at once, so i had to track it in the cg-store.&lt;br&gt;
So I went with an easier solution and that was to fallback to a simpler progress dialog without the exact image count.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdyyb03z6byg0bss6d995.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdyyb03z6byg0bss6d995.png" alt="Previous Progress Dialog" width="702" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm7z1df1hm7yi50hs79u1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm7z1df1hm7yi50hs79u1.png" alt="Current Progress Dialog" width="702" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Preventing UI Freeze
&lt;/h3&gt;

&lt;p&gt;The third issue was actually the ui-freeze problem for these c-queries.&lt;br&gt;
The solution for that was rather simple too and that was to wrap these sync functions into functions which would then call these in the background(as async) using thread executors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;For initial queries I added manual tests, but then with UI - inclusion etc. We stuck with manual verification / testing.&lt;br&gt;
Checking multiple combinations of steps etc. And paulo was a big help here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technology Used
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Language: Python&lt;/li&gt;
&lt;li&gt;Libraries: pynetdicom, pydicom, wx, logging&lt;/li&gt;
&lt;li&gt;Tools: docker, Orthanc, dcm4chee, git&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Learned the docker yml structure and docker commands.&lt;/li&gt;
&lt;li&gt;Learned how medical networks work with the top layer using application entities along with ip: ports and how under the hood tcp is used.&lt;/li&gt;
&lt;li&gt;Learned how different DICOM servers work and how they are configured.&lt;/li&gt;
&lt;li&gt;Learned how DICOM servers are queried for different needs and what context does each require.&lt;/li&gt;
&lt;li&gt;Learned how to skim through documentation to understand new technology and to understand the error codes etc.&lt;/li&gt;
&lt;li&gt;Learned how important logs are for documentation.&lt;/li&gt;
&lt;li&gt;Learned how to better handle the errors.&lt;/li&gt;
&lt;li&gt;And a lot more.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Open Source
&lt;/h2&gt;

&lt;p&gt;So my journey consists of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Being confused&lt;/li&gt;
&lt;li&gt;Starting from an existing stale PR (#644).&lt;/li&gt;
&lt;li&gt;Being confused&lt;/li&gt;
&lt;li&gt;understanding the previous implementation and the existing codebase before continuing.&lt;/li&gt;
&lt;li&gt;Adapted/extended the work to fit the current codebase.&lt;/li&gt;
&lt;li&gt;Debugging&lt;/li&gt;
&lt;li&gt;Debugging again&lt;/li&gt;
&lt;li&gt;going through mentor/code review and iteration.&lt;/li&gt;
&lt;li&gt;Getting my PR merged (oh the joy of it)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Future Work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Querying multiple nodes/servers at once.&lt;/li&gt;
&lt;li&gt;Editing nodes information&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;So this project took me from being completely unaware of what DICOM was to implementing PACS integration for InVesalius, handling edge cases and cancellation flows, understanding status codes from different servers, reading documentation, debugging issues, and a lot more along the way. It was a great experience and I would say everyone should contribute to OSS to the software they use and enjoy.&lt;/p&gt;

&lt;p&gt;Well, if you reached this point consider commenting :)&lt;/p&gt;

&lt;h2&gt;
  
  
  PR
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhzcmpdav5ejmvm0bben3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhzcmpdav5ejmvm0bben3.png" alt="Merged PR" width="795" height="68"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;PR Link: &lt;a href="https://github.com/invesalius/invesalius3/pull/1261" rel="noopener noreferrer"&gt;https://github.com/invesalius/invesalius3/pull/1261&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Special Mentions
&lt;/h2&gt;

&lt;p&gt;Special thanks to my mentor &lt;a href="https://www.linkedin.com/in/paulojamorim/" rel="noopener noreferrer"&gt;Paulo&lt;/a&gt; for his continued guidance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Socials
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/in/muneeb-ahmad-se/" rel="noopener noreferrer"&gt;Linkedin&lt;/a&gt;&lt;br&gt;
&lt;a href="https://github.com/muneeb-Ahmad-404/" rel="noopener noreferrer"&gt;Github&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>python</category>
      <category>dicom</category>
      <category>backenddevelopment</category>
    </item>
  </channel>
</rss>
