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 BuiltWhat I Should’ve Built
Custom Go backend with service layersNext.js API routes (migrate to Go later)
Multi-stage CI/CD with load balancingVercel deploy button
Custom email queuing systemResend (one API call)
3 containerized Docker instancesSingle Vercel deployment
Custom auth with JWT + refresh tokensSupabase Auth + GitHub OAuth
Interface-based dependency injectionDirect database calls
Red-green deployment strategiesPush to main, auto-deploy
2-week billing system integrationStripe Checkout (30 minutes)
Custom error types with metadataStandard HTTP errors
Structured logging infrastructureConsole.log (seriously)
Custom slug generation + validationSupabase 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…