NestJS vs. Ruby on Rails
When you're building web applications, you'll encounter two distinct approaches to solving the same problems. NestJS brings enterprise-grade architecture and TypeScript safety to Node.js development. Ruby on Rails prioritizes rapid development through well-established conventions that eliminate common decision points.
NestJS was created because Express applications frequently became unmaintainable as they scaled. Development teams would start with clear intentions, but without structured patterns, codebases would evolve into complex, difficult-to-understand systems. NestJS addresses this by implementing Angular's architectural patterns for backend development, providing predictable structure from the start.
Ruby on Rails emerged from David Heinemeier Hansson's experience building Basecamp, where he repeatedly implemented the same patterns for common web application features. Rather than rebuilding these solutions for each project, he extracted them into a framework that handles routine tasks automatically.
The key difference lies in their approach to complexity management. NestJS requires upfront architectural decisions but provides long-term maintainability through explicit structure. Rails eliminates many architectural decisions through conventions, enabling rapid feature development but requiring discipline to maintain code organization as applications grow.
What is NestJS?
Express.js provided JavaScript developers with flexibility, but this freedom often created problems in larger applications. Teams would implement custom middleware stacks, invent unique folder structures, and develop project-specific patterns. As projects matured, these custom solutions became barriers to team productivity and code maintainability.
NestJS solves this problem by applying proven architectural patterns from Angular to backend JavaScript development. Instead of creating new organizational systems for each project, you follow established patterns that experienced developers recognize immediately.
The framework implements several key concepts that promote maintainable code:
- Dependency injection manages component relationships explicitly
- Decorators provide metadata for routing and validation
- Modules organize related functionality into cohesive units
- Guards and interceptors handle cross-cutting concerns like authentication
This structured approach reduces the cognitive load of understanding codebases and makes it easier for new team members to contribute effectively.
What is Ruby on Rails?
Before Rails, web application development required implementing basic functionality repeatedly. Authentication systems, database migrations, URL routing, and session management had to be built from scratch for each project. This redundancy slowed development and introduced inconsistencies between applications.
Rails addressed this inefficiency by providing pre-built solutions for common web application needs. The framework includes integrated tools for database management, user authentication, asset compilation, and deployment configuration.
Rails achieves productivity through several core principles:
- Convention over configuration reduces the number of decisions developers need to make
- DRY (Don't Repeat Yourself) promotes code reuse through shared components
- Active Record pattern provides intuitive database interaction methods
- Integrated generators create working code for common features
These conventions create consistency across Rails applications, making it easier to work on different projects and onboard new developers.
Framework comparison
Understanding how these frameworks approach common development tasks will help you determine which fits your project needs.
Aspect | NestJS | Ruby on Rails |
---|---|---|
Language | TypeScript/JavaScript | Ruby |
Architecture | Explicit dependency injection | Convention over configuration |
Learning Curve | Steeper initial learning, familiar to Angular developers | Gentler learning curve with clear conventions |
Development Speed | Slower initial development, faster iteration once structured | Extremely fast initial development |
Type Safety | Compile-time type checking with TypeScript | Runtime type checking, optional static analysis |
Database Integration | TypeORM/Prisma require explicit configuration | Active Record provides built-in ORM |
Testing Approach | Jest with dependency mocking | Built-in test framework with database fixtures |
Deployment Strategy | Container-friendly, requires Node.js runtime | Traditional hosting, requires Ruby runtime |
Community Ecosystem | Growing, enterprise-focused packages | Mature ecosystem with established gems |
Hosting Requirements | Any Node.js-compatible platform | Ruby-specific hosting or containers |
Your choice between these frameworks often depends on your team's background and project timeline. Teams with TypeScript experience will find NestJS's patterns familiar. Teams prioritizing rapid feature delivery may prefer Rails' integrated approach.
Getting started
Let's examine how each framework handles initial project setup to understand their different philosophies in practice.
NestJS emphasizes structure from the first command:
npm install -g @nestjs/cli
nest new blog-api
cd blog-api && npm run start:dev
The CLI generates a project structure that demonstrates the framework's architectural patterns:
src/
├── app.controller.ts # HTTP request handler
├── app.module.ts # Application organization
├── app.service.ts # Business logic
└── main.ts # Application bootstrap
Creating your first API endpoint requires understanding dependency injection and separation of concerns:
import { Controller, Get, Post, Body } from '@nestjs/common';
import { PostsService } from './posts.service';
import { CreatePostDto } from './dto/create-post.dto';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Get()
getAllPosts() {
return this.postsService.findAll();
}
@Post()
createPost(@Body() postData: CreatePostDto) {
return this.postsService.create(postData);
}
}
This example demonstrates NestJS's approach to code organization. The controller handles HTTP requests but delegates business logic to the service. Data validation occurs through DTOs (Data Transfer Objects). Dependencies are injected through constructor parameters. This separation creates predictable code structure but requires understanding these patterns before you can implement features effectively.
Rails gets you building features immediately:
gem install rails
rails new blog_app
cd blog_app && rails server
Rails generates a complete web application with all necessary components:
rails generate scaffold Post title:string content:text published:boolean
rails db:migrate
This single command creates a working web interface with database integration, including:
- Database migration files
- ActiveRecord model with validations
- Controller with full CRUD operations
- HTML views for all actions
- URL routing configuration
The generated controller demonstrates Rails' approach:
class PostsController < ApplicationController
before_action :set_post, only: [:show, :edit, :update, :destroy]
def index
@posts = Post.published.order(created_at: :desc)
end
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post, notice: 'Post was successfully created.'
else
render :new
end
end
private
def post_params
params.require(:post).permit(:title, :content, :published)
end
end
Rails provides working functionality immediately without requiring architectural decisions. The framework makes assumptions about how you want to structure your application and provides sensible defaults for common patterns.
Database integration
Database interaction reveals the core differences between these frameworks most clearly.
NestJS treats database integration as a service that requires explicit configuration. Using TypeORM, you define entities and repositories:
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn } from 'typeorm';
import { User } from '../users/user.entity';
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column('text')
content: string;
@ManyToOne(() => User, user => user.posts)
author: User;
@Column({ default: false })
published: boolean;
@CreateDateColumn()
createdAt: Date;
}
Database queries use repository methods with explicit type safety:
@Injectable()
export class PostsService {
constructor(
@InjectRepository(Post) private postRepository: Repository<Post>
) {}
async findPublished(): Promise<Post[]> {
return this.postRepository.find({
where: { published: true },
relations: ['author'],
order: { createdAt: 'DESC' }
});
}
async createPost(postData: CreatePostDto, authorId: number): Promise<Post> {
const post = this.postRepository.create({
...postData,
author: { id: authorId }
});
return this.postRepository.save(post);
}
}
Many NestJS developers prefer Prisma for better TypeScript integration:
model Post {
id Int @id @default(autoincrement())
title String
content String
published Boolean @default(false)
authorId Int
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
}
async findPostsWithAuthors() {
return this.prisma.post.findMany({
where: { published: true },
include: { author: true },
orderBy: { createdAt: 'desc' }
});
}
Rails integrates database operations into the framework's core patterns through Active Record:
class Post < ApplicationRecord
belongs_to :user
has_many_attached :images
validates :title, presence: true, length: { minimum: 5 }
validates :content, presence: true
scope :published, -> { where(published: true) }
scope :recent, ->(limit = 10) { order(created_at: :desc).limit(limit) }
def excerpt(length = 200)
content.truncate(length)
end
end
Database queries use methods that read like natural language:
# Find published posts with authors
@posts = Post.includes(:user)
.published
.recent
# Create a new post
@post = current_user.posts.create(
title: "New Post",
content: "Post content here",
published: true
)
# Complex queries with conditions
@featured_posts = Post.joins(:user)
.where("posts.created_at > ? AND users.verified = ?",
1.week.ago, true)
.published
Rails migrations provide version control for database schema changes:
class AddTagsToPosts < ActiveRecord::Migration[7.0]
def change
create_table :tags do |t|
t.string :name, null: false
t.timestamps
end
create_join_table :posts, :tags
add_index :tags, :name, unique: true
end
end
The contrast demonstrates each framework's priorities. NestJS requires explicit understanding of database operations but provides compile-time safety and clear dependency management. Rails hides database complexity behind intuitive Ruby methods, enabling faster development but potentially obscuring performance implications.
Authentication and authorization
Authentication implementation showcases how these frameworks balance security with developer productivity.
NestJS implements authentication through explicit service layers and guard mechanisms:
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { AuthService } from './auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(payload: any) {
const user = await this.authService.validateUser(payload.sub);
if (!user) {
throw new UnauthorizedException('Token validation failed');
}
return user;
}
}
Route protection uses decorators that make security requirements visible:
@Controller('posts')
export class PostsController {
@Get('my-posts')
@UseGuards(JwtAuthGuard)
getUserPosts(@Request() req) {
return this.postsService.findByUser(req.user.id);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, OwnershipGuard)
deletePost(@Param('id') id: string, @Request() req) {
return this.postsService.deleteUserPost(id, req.user.id);
}
}
Custom guards can implement complex authorization logic:
@Injectable()
export class OwnershipGuard implements CanActivate {
constructor(private postsService: PostsService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const postId = request.params.id;
const userId = request.user.id;
const post = await this.postsService.findOne(postId);
return post && post.author.id === userId;
}
}
Rails integrates authentication into the framework's conventional patterns:
class User < ApplicationRecord
has_secure_password
has_many :posts, dependent: :destroy
validates :email, presence: true, uniqueness: true
validates :password, length: { minimum: 8 }, if: :password_digest_changed?
def generate_jwt_token
JWT.encode({ user_id: id, exp: 24.hours.from_now.to_i },
Rails.application.secrets.secret_key_base)
end
end
Controller-level authentication uses before-action callbacks:
class ApplicationController < ActionController::Base
before_action :authenticate_user!
private
def authenticate_user!
token = request.headers['Authorization']&.split(' ')&.last
return render_unauthorized unless token
begin
decoded_token = JWT.decode(token, Rails.application.secrets.secret_key_base)
@current_user = User.find(decoded_token[0]['user_id'])
rescue JWT::DecodeError, ActiveRecord::RecordNotFound
render_unauthorized
end
end
def current_user
@current_user
end
end
Authorization happens through straightforward conditional logic:
class PostsController < ApplicationController
before_action :set_post, only: [:show, :edit, :update, :destroy]
before_action :require_ownership, only: [:edit, :update, :destroy]
def create
@post = current_user.posts.build(post_params)
if @post.save
render json: @post, status: :created
else
render json: @post.errors, status: :unprocessable_entity
end
end
private
def require_ownership
unless @post.user == current_user
render json: { error: 'Unauthorized' }, status: :forbidden
end
end
end
The authentication approaches reflect each framework's core values. NestJS provides explicit, testable security components with clear separation of concerns. Rails integrates security into existing patterns, making it accessible to developers without specialized security knowledge.
Testing strategies
Testing approaches reveal how these frameworks prioritize code quality and maintainability.
NestJS emphasizes unit testing with comprehensive dependency mocking:
describe('PostsService', () => {
let service: PostsService;
let mockRepository: jest.Mocked<Repository<Post>>;
beforeEach(async () => {
const mockRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
PostsService,
{
provide: getRepositoryToken(Post),
useValue: mockRepo
}
],
}).compile();
service = module.get<PostsService>(PostsService);
mockRepository = module.get(getRepositoryToken(Post));
});
it('should find published posts', async () => {
const mockPosts = [
{ id: 1, title: 'Test Post', published: true },
{ id: 2, title: 'Another Post', published: true }
];
mockRepository.find.mockResolvedValue(mockPosts as Post[]);
const result = await service.findPublished();
expect(mockRepository.find).toHaveBeenCalledWith({
where: { published: true },
relations: ['author'],
order: { createdAt: 'DESC' }
});
expect(result).toEqual(mockPosts);
});
});
Integration tests verify complete request flows:
describe('PostsController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/posts (GET)', () => {
return request(app.getHttpServer())
.get('/posts')
.expect(200)
.expect((res) => {
expect(Array.isArray(res.body)).toBe(true);
});
});
});
Rails provides integrated testing tools that work with the framework's conventions:
# test/models/post_test.rb
class PostTest < ActiveSupport::TestCase
test "should validate presence of title" do
post = Post.new(content: "Some content", published: true)
assert_not post.valid?
assert_includes post.errors[:title], "can't be blank"
end
test "should create post with valid attributes" do
post = posts(:published_post) # fixture reference
assert post.valid?
assert post.published?
end
test "published scope should return only published posts" do
published_count = Post.where(published: true).count
assert_equal published_count, Post.published.count
end
end
Controller tests verify HTTP interactions:
# test/controllers/posts_controller_test.rb
class PostsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:john)
@post = posts(:published_post)
end
test "should get index" do
get posts_url
assert_response :success
assert_includes response.body, @post.title
end
test "should create post when authenticated" do
sign_in @user
assert_difference('Post.count') do
post posts_url, params: {
post: {
title: 'New Post',
content: 'Post content',
published: true
}
}
end
assert_redirected_to post_url(Post.last)
end
test "should require authentication for create" do
assert_no_difference('Post.count') do
post posts_url, params: {
post: { title: 'Unauthorized Post' }
}
end
assert_redirected_to login_url
end
end
Rails fixtures provide consistent test data:
# test/fixtures/posts.yml
published_post:
title: "Published Post"
content: "This post is published"
published: true
user: john
draft_post:
title: "Draft Post"
content: "This post is a draft"
published: false
user: jane
The testing approaches align with each framework's architecture. NestJS promotes isolated unit tests that verify individual components, supporting maintainable code through explicit dependencies. Rails emphasizes integration tests that verify complete feature functionality, ensuring the application works correctly from the user's perspective.
Background processing
Background job handling demonstrates how these frameworks approach asynchronous task processing.
NestJS uses Bull queues with Redis for sophisticated job management:
// email.module.ts
import { BullModule } from '@nestjs/bull';
@Module({
imports: [
BullModule.registerQueue({
name: 'email-processing',
redis: {
host: 'localhost',
port: 6379,
},
}),
],
providers: [EmailService, EmailProcessor],
})
export class EmailModule {}
Job processors handle background tasks with explicit configuration:
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
@Processor('email-processing')
export class EmailProcessor {
constructor(private emailService: EmailService) {}
@Process('welcome-email')
async sendWelcomeEmail(job: Job<{ userId: number }>) {
const { userId } = job.data;
try {
const user = await this.userService.findById(userId);
await this.emailService.sendWelcomeEmail(user.email, user.name);
console.log(`Welcome email sent successfully to user ${userId}`);
} catch (error) {
console.error(`Failed to send welcome email to user ${userId}:`, error);
throw error; // This will trigger job retry
}
}
@Process('newsletter')
async sendNewsletter(job: Job<{ userIds: number[]; newsletterId: string }>) {
const { userIds, newsletterId } = job.data;
for (const userId of userIds) {
await this.emailService.sendNewsletter(userId, newsletterId);
}
}
}
Services queue jobs with detailed options:
@Injectable()
export class UserService {
constructor(@InjectQueue('email-processing') private emailQueue: Queue) {}
async createUser(userData: CreateUserDto): Promise<User> {
const user = await this.userRepository.save(userData);
// Queue welcome email with retry configuration
await this.emailQueue.add('welcome-email',
{ userId: user.id },
{
delay: 5000, // Wait 5 seconds before processing
attempts: 3, // Retry up to 3 times on failure
backoff: 'exponential' // Use exponential backoff for retries
}
);
return user;
}
}
Rails traditionally uses background job libraries like Sidekiq with Active Job integration:
# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
retry_on StandardError, wait: :exponentially_longer, attempts: 3
discard_on ActiveJob::DeserializationError
end
class WelcomeEmailJob < ApplicationJob
queue_as :default
def perform(user)
UserMailer.welcome_email(user).deliver_now
Rails.logger.info "Welcome email sent to #{user.email}"
rescue => e
Rails.logger.error "Failed to send welcome email to #{user.email}: #{e.message}"
raise e # Re-raise to trigger retry mechanism
end
end
class NewsletterJob < ApplicationJob
queue_as :newsletters
def perform(newsletter_id)
newsletter = Newsletter.find(newsletter_id)
newsletter.subscribers.find_each do |user|
UserMailer.newsletter(user, newsletter).deliver_now
end
end
end
Rails controllers queue jobs using simple method calls:
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
# Queue welcome email to be sent asynchronously
WelcomeEmailJob.perform_later(@user)
render json: @user, status: :created
else
render json: @user.errors, status: :unprocessable_entity
end
end
end
Scheduled jobs use declarative syntax:
# app/jobs/cleanup_job.rb
class CleanupJob < ApplicationJob
def perform
# Remove unconfirmed users after 30 days
User.where('created_at < ? AND confirmed_at IS NULL', 30.days.ago)
.destroy_all
# Clean up old log files
Dir.glob(Rails.root.join('log', '*.log.*')).each do |file|
File.delete(file) if File.mtime(file) < 7.days.ago
end
end
end
# config/schedule.rb (using whenever gem)
every 1.day, at: '2:00 am' do
runner "CleanupJob.perform_later"
end
Both approaches provide reliable background processing, but with different complexity trade-offs. NestJS requires explicit Redis configuration and provides fine-grained control over job processing. Rails integrates background jobs into existing patterns with minimal configuration requirements.
Final thoughts
This article covered the key differences between NestJS and Ruby on Rails, showing how each framework approaches web development through different priorities and patterns.
Both frameworks create production-ready applications that can scale effectively. NestJS scales through architectural discipline and explicit component boundaries. Rails scales through caching strategies, database optimization, and horizontal deployment patterns.
Consider building a small prototype in both frameworks to evaluate which feels more natural for your specific use case. The framework that enables you to write maintainable code efficiently is the correct choice for your project.