Why I'm Killing My SaaS Before Launch
Jan 17, 2026
Two weeks. That’s how long it took me to build a billing system.
Not the entire product-just billing. Subscription tiers, usage tracking, webhook handlers, invoice generation. Two weeks of fighting Stripe’s API, debugging webhook signatures, and building retry logic for failed payments.
Meanwhile, the rest of my SaaS-the actual product-sat at 70,000 lines of code across 350 files. A developer productivity platform that connected Slack, GitHub, email, and project management tools into one unified workspace. The thing I’d spent two months building from scratch.
And that’s when I knew I had to kill it.
What I Actually Built
Let me be clear: this wasn’t a side project. This was production-grade enterprise software.
The scale:
- 70,000+ lines of code
- 350+ production files
- Full CI/CD pipeline with multi-stage deployments
- 3 containerized instances with load balancing
- Red-green and rolling deployment strategies
- Custom email queuing system
- Modular architecture with dependency injection
- 100% test coverage with interface mocking
- Comprehensive documentation (dev-only frontend docs)
- Cyclomatic complexity linting
- Multi-tenant organization system with RLS
The tech stack:
- Frontend: Next.js 16.1.3, React, TypeScript, Tailwind CSS, shadcn/ui, Radix UI, Framer Motion
- Backend: Go, PostgreSQL, SQLc, PGX
- Infrastructure: Docker, GitHub Actions (GHCR), Caddy, custom VPS deployment
- Services: Stripe, Supabase, Sentry, hCaptcha
- Tooling: Turbopack, Makefiles, automated testing pipelines
This wasn’t a startup. This was infrastructure that could handle millions of users. The kind of system worth $500k+ if you hired a team to build it.
And that was the problem.
The Over-Engineering
Here’s what over-engineering actually looks like. This is from my organization invitation system-just one small part of the entire codebase:
// InviteMember creates an invitation for a user to join an organization.
//
//nolint:gocyclo // Complex validation logic with multiple checks
func (s *Service) InviteMember(ctx context.Context, userID, orgID uuid.UUID, req InviteMemberRequest) (*InviteMemberResponse, error) {
// Validate email format
if req.Email == "" {
return nil, apperrors.ValidationFailed("Email is required").
WithDetails("email", "email address is required")
}
// Basic email validation
emailPattern := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
if !emailPattern.MatchString(req.Email) {
return nil, apperrors.ValidationFailed("Invalid email format").
WithDetails("email", "please provide a valid email address")
}
// Validate role
if req.Role != roleOwner && req.Role != roleAdmin && req.Role != "member" {
return nil, apperrors.ValidationFailed("Invalid role").
WithDetails("role", "must be one of: owner, admin, member")
}
// Check if user has permission (must be owner or admin)
member, err := s.repo.GetOrganizationMember(ctx, organizations.GetOrganizationMemberParams{
OrganizationID: pgtype.UUID{Bytes: orgID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
if err != nil {
return nil, apperrors.Forbidden("You do not have access to this organization")
}
role := string(member.Role)
if role != roleOwner && role != roleAdmin {
return nil, apperrors.Forbidden("Only owners and admins can invite members")
}
// Check if invitation already exists for this email
existingInvitation, err := s.repo.GetPendingInvitationByEmail(ctx, orgID, req.Email)
if err == nil && existingInvitation != nil && existingInvitation.Status == organizations.InvitationStatusPending {
return nil, apperrors.ValidationFailed("Invitation already sent").
WithDetails("email", "an invitation has already been sent to this email address")
}
// Convert role to enum
var roleEnum organizations.OrganizationRole
switch req.Role {
case roleOwner:
roleEnum = organizations.OrganizationRoleOwner
case roleAdmin:
roleEnum = organizations.OrganizationRoleAdmin
case "member":
roleEnum = organizations.OrganizationRoleMember
default:
return nil, apperrors.ValidationFailed("Invalid role").
WithDetails("role", "must be one of: owner, admin, member")
}
// Create invitation (expires in 7 days)
expiresAt := time.Now().Add(7 * 24 * time.Hour)
invitation, err := s.repo.CreateInvitation(ctx, organizations.CreateInvitationParams{
OrganizationID: pgtype.UUID{Bytes: orgID, Valid: true},
Email: req.Email,
Role: roleEnum,
InvitedBy: pgtype.UUID{Bytes: userID, Valid: true},
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
})
if err != nil {
logger.Error(ctx, "Failed to create invitation", err, map[string]any{
"organization_id": orgID.String(),
"email": req.Email,
"role": req.Role,
})
return nil, apperrors.Internal("Failed to create invitation")
}
logger.Info(ctx, "Invitation created successfully", map[string]any{
"invitation_id": uuid.UUID(invitation.ID.Bytes).String(),
"organization_id": orgID.String(),
"email": req.Email,
"role": req.Role,
"invited_by": userID.String(),
})
// Send invitation email asynchronously
invitationID := uuid.UUID(invitation.ID.Bytes)
s.sendInvitationEmailAsync(ctx, userID, orgID, invitationID, invitation.Email, string(invitation.Role), invitation.ExpiresAt.Time)
return &InviteMemberResponse{
InvitationID: uuid.UUID(invitation.ID.Bytes).String(),
Email: invitation.Email,
Role: string(invitation.Role),
ExpiresAt: invitation.ExpiresAt.Time.Format(time.RFC3339),
}, nil
}
That’s just for inviting someone to an organization. Over 60 lines of validation, permission checks, error handling, structured logging, and custom error types.
Here’s what it should’ve been for an MVP:
// Simple Supabase approach
const { error } = await supabase.from("organization_invitations").insert({
organization_id: orgId,
email: email,
role: role,
invited_by: userId,
});
if (error) throw error;
await sendInvitationEmail(email, orgName);
Eight lines. Versus 60+ lines of Go with custom error handling, detailed validation, structured logging, and async email sending.
To be clear: if I were building this today at scale, I’d still choose Go. Custom backends scale better, give you more control, and handle complex business logic elegantly. But I wouldn’t write 70,000 lines. I’d write maybe 10,000-focused on the core business logic that actually differentiates the product, not reimplementing what Supabase already does well.
What I Built vs. What I Should’ve Built
| What I Built | What I Should’ve Built |
|---|---|
| Custom Go backend with service layers | Next.js API routes (migrate to Go later) |
| Multi-stage CI/CD with load balancing | Vercel deploy button |
| Custom email queuing system | Resend (one API call) |
| 3 containerized Docker instances | Single Vercel deployment |
| Custom auth with JWT + refresh tokens | Supabase Auth + GitHub OAuth |
| Interface-based dependency injection | Direct database calls |
| Red-green deployment strategies | Push to main, auto-deploy |
| 2-week billing system integration | Stripe Checkout (30 minutes) |
| Custom error types with metadata | Standard HTTP errors |
| Structured logging infrastructure | Console.log (seriously) |
| Custom slug generation + validation | Supabase database constraints |
Every single “enterprise feature” I built had a dead-simple alternative for getting started. I could have validated the product in weeks, then gradually migrated the hot paths to Go as scale demanded it.
But I didn’t do that because I wanted to learn. And I did learn-I now know how to build enterprise systems that can scale to millions of users. I just learned it at the cost of never shipping.
The Reality Check
The integration problem:
The core idea was simple: eliminate context switching by unifying Slack, GitHub, email, and project tools into one workspace. Focus on one project at a time.
The execution was hell.
Slack’s API has rate limits. GitHub’s webhook events are inconsistent. Email parsing is a nightmare. And syncing everything in real-time? Even Linear-a company with millions in funding and a world-class team-doesn’t do cross-platform integration well.
I realized this before I even started building the integrations. After spending 2 months on infrastructure, I looked at the integration requirements and thought: “If Linear can’t do this well, why do I think I can as a solo 15-year-old?”
The answer: I probably can’t. Not as my first SaaS. Not when I need to learn marketing, sales, customer development, and viral content creation on top of the technical execution.
The skepticism:
I got 100 visitors per day to my landing page. 50 people joined the waitlist.
Then I started talking about it publicly.
The moment people learned my age, the questions started:
- “How could a 15-year-old build enterprise software?”
- “Is this actually production-ready or just a learning project?”
- “Do you even understand what you’re building?”
I thought the code would speak for itself. It didn’t. The dev community is skeptical-rightfully so. A teenager claiming to build what venture-backed teams struggle with? It sounds like nonsense, even when it isn’t.
And here’s the thing: I can show them the 70,000 lines of Go. I can walk them through the architecture. But nobody wants to audit my codebase. They want to see traction. They want to see users. T…