How One 1,600-Line Node.js Service Replaced Five Microservices as Our Entire Customer-Facing API
The Pain Point
You're running 25+ microservices. Business is evolving fast — new product funnels, new intake forms, new checkout flows. Every feature request turns into the same conversation: "Should this be a new service?"
If you say yes every time, you end up with a sprawl problem. Another port to allocate. Another systemd unit to manage. Another health check to wire into monitoring. Another service that can go down independently and leave customers staring at a 502.
We hit this wall at Ledd Consulting. We had a contact form service, and then we needed a prompt audit scoring endpoint, and then a Stripe checkout flow, and then webhook handling, and then site analytics tracking, and then a CRM API. Each one felt like its own concern. Each one almost justified its own service.
We tried splitting them. It lasted two weeks before we consolidated everything back into a single gateway.
Why Common Solutions Fall Short
The "One Service Per Endpoint" Reflex
The textbook answer is clear separation of concerns. Contact form handling is different from payment processing is different from analytics. So you build contact-form-service, checkout-service, webhook-service, analytics-service, and crm-api-service.
On a team of 20 with a Kubernetes cluster, this is fine. On a solo infrastructure running on a single VPS, it's a tax you pay on every deploy, every restart, every outage investigation.
When our checkout service went down at 2 AM and a customer paid but never got their report, we had to trace across three services to find the gap. The webhook arrived at webhook-service, which tried to call fulfillment-service, which was restarting because of a memory leak in analytics-service that had consumed all available RAM on the box.
The Express/Fastify Framework Approach
The other common path: reach for a framework. But frameworks add dependency trees, middleware stacks, and abstraction layers that a service this focused doesn't need. Node's built-in http module gives you everything required for a service that handles 10 routes.
We're not anti-framework. We use them where they earn their keep. But a customer-facing gateway that needs to stay up and stay simple? Raw http.createServer with explicit routing is easier to reason about at 2 AM.
Our Approach
The design principle: one service owns the entire public surface. Every HTTP request from a customer — form submission, checkout initiation, webhook callback, analytics event — hits the same process. That process handles validation, rate limiting, spam detection, and dispatch to internal services.
Internal services (the scoring engine, the PDF generator, the notification router) stay internal. They never see a public request directly. The gateway is the only thing nginx proxies to from the outside.
This gives us:
- One port to monitor — if the gateway is up, customers can reach us
- One place for rate limiting — every endpoint shares the same IP-tracking logic
- One place for spam detection — the classification layer protects all routes
- One place for CORS — no per-service origin configuration
- One failure domain — if the gateway is down, we know immediately; we don't have to check five services
Implementation
The Core Server: Explicit Routing Over Middleware
The entire service is a single http.createServer with explicit route matching. No middleware chains, no routing libraries:
const http = require('http');
const fs = require('fs');
const crypto = require('crypto');
const PORT = 3000;
const ALLOWED_ORIGINS = new Set(
(process.env.ALLOWED_ORIGINS || 'https://example.com')
.split(',')
.map(origin => origin.trim())
.filter(Boolean)
);
const ROUTE_CONFIGS = {
'/submit': {
source: 'website',
entryPoint: 'website-contact',
defaultOffering: 'Website inquiry',
successMessage: "We'll be in touch within 24 hours."
},
'/audit-request': {
source: 'security-audit',
entryPoint: 'security-audit',
defaultOffering: 'Security + Architecture Audit',
successMessage: 'Audit request received. We will reply within 24 hours.'
}
};
const server = http.createServer((req, res) => {
setCORSHeaders(res, req);
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === 'GET' && url.pathname === '/health') {
sendJSON(res, 200, {
status: 'ok',
service: 'contact-form',
uptime: process.uptime(),
routes: [
...Object.keys(ROUTE_CONFIGS),
'/track', '/prompt-audit-score',
'/audit-checkout', '/stripe-webhook',
'/consulting-leads'
],
stripe_configured: !!process.env.STRIPE_SECRET_KEY,
});
return;
}
if (req.method === 'POST' && url.pathname === '/prompt-audit-score') {
handlePromptAuditScore(req, res).catch(err => {
if (!res.headersSent) sendJSON(res, 500, { success: false });
});
return;
}
if (req.method === 'POST' && url.pathname === '/stripe-webhook') {
handleStripeWebhook(req, res).catch(err => {
if (!res.headersSent) sendJSON(res, 500, { error: 'Webhook failed.' });
});
return;
}
const routeConfig = ROUTE_CONFIGS[url.pathname];
if (req.method === 'POST' && routeConfig) {
processSubmission(req, res, routeConfig);
return;
}
sendJSON(res, 404, { success: false, message: 'Not found.' });
});
server.listen(PORT, '127.0.0.1');
Every route is visible in one screen. There's no app.use() chain to trace through. When something breaks, you grep for the path and you're looking at the handler.
Shared Rate Limiting Across All Routes
Every public endpoint shares one rate limiter. This is the kind of thing that's painful to coordinate across multiple services but trivial in a single process:
const rateLimitMap = new Map();
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
function isRateLimited(ip) {
const now = Date.now();
const entries = (rateLimitMap.get(ip) || [])
.filter(t => now - t < RATE_LIMIT_WINDOW);
rateLimitMap.set(ip, entries);
if (entries.length >= RATE_LIMIT_MAX) return true;
entries.push(now);
rateLimitMap.set(ip, entries);
return false;
}
// Cleanup stale entries every 10 minutes
setInterval(() => {
const now = Date.now();
for (const [ip, entries] of rateLimitMap.entries()) {
const valid = entries.filter(t => now - t < RATE_LIMIT_WINDOW);
if (valid.length === 0) rateLimitMap.delete(ip);
else rateLimitMap.set(ip, valid);
}
}, 10 * 60 * 1000);
In-memory, no Redis, no shared store. This works because there's exactly one process handling all public traffic. If we had five services, we'd need a shared rate-limit backend — another dependency, another failure mode.
The Spam Gate: Classify Before Anything Touches the Pipeline
Every form submission passes through a classification layer before it reaches lead storage, CRM, or notifications. Rejected submissions get a 200 response (so attackers don't know they were filtered) but never enter the pipeline:
const DISPOSABLE_DOMAINS = new Set([
'mailinator.com', 'guerrillamail.com', 'tempmail.com',
'throwaway.email', 'yopmail.com', 'sharklasers.com'
]);
const INJECTION_PATTERNS = [
/<script/i, /javascript:/i, /on\w+\s*=/i,
/ignore\s+(all\s+)?previous\s+instructions/i,
/\{\{.*\}\}/, /\$\{.*\}/,
/UNION\s+SELECT/i, /DROP\s+TABLE/i, /;\s*DELETE/i,
/\x00/, /\x1b/
];
function classifySubmission(name, email, message, company) {
const domain = email.toLowerCase().split('@')[1] || '';
const localPart = email.toLowerCase().split('@')[0] || '';
const allText = `${name} ${email} ${message} ${company || ''}`;
for (const pattern of INJECTION_PATTERNS) {
if (pattern.test(allText)) {
return { verdict: 'attack', reason: `injection pattern: ${pattern}` };
}
}
if (DISPOSABLE_DOMAINS.has(domain)) {
return { verdict: 'test', reason: `disposable domain: ${domain}` };
}
const cleanName = name.trim();
if (cleanName.length < 2 || /^(.)\1+$/.test(cleanName)) {
return { verdict: 'spam', reason: `suspicious name: "${cleanName}"` };
}
if (message.trim().length < 10) {
return { verdict: 'spam', reason: 'message too short' };
}
return { verdict: 'legitimate', reason: null };
}
Notice the prompt injection detection (ignore all previous instructions) sitting alongside SQL injection and XSS patterns. When your gateway also proxies to AI scoring services, injection means something different than it did five years ago.
The key design choice: rejected submissions still return the success message. The handler responds with "We'll be in touch within 24 hours" whether the submission is legitimate or garbage. Attackers get no signal about what was filtered.
Lead Quality Scoring at Intake
Every submission is tagged with quality metadata the moment it arrives. Corporate vs. personal email is the simplest signal, but it feeds directly into CRM prioritization:
const FREE_EMAIL_DOMAINS = new Set([
'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com',
'aol.com', 'icloud.com', 'protonmail.com', 'mail.com'
]);
function buildLead(data, clientIP, routeConfig) {
const emailDomain = data.email.trim().toLowerCase().split('@')[1] || '';
const isCorporate = !FREE_EMAIL_DOMAINS.has(emailDomain);
return {
id: crypto.randomUUID(),
name: data.name.trim().substring(0, 200),
email: data.email.trim().toLowerCase().substring(0, 200),
company: sanitizeOptional(data.company, 200),
message: data.message.trim().substring(0, 5000),
ip: clientIP,
timestamp: new Date().toISOString(),
source: routeConfig.source,
entry_point: routeConfig.entryPoint,
offering: sanitizeOptional(data.offering, 160) || routeConfig.defaultOffering,
quality: isCorporate ? 'corporate' : 'personal'
};
}
This is trivial logic, but it matters because the CRM entry, notification priority, and follow-up timing all key off quality. Having it computed once, in the gateway, means every downstream consumer sees the same classification.
Stripe Checkout and Webhook: Same Process, Zero Coordination
The checkout flow creates a Stripe session with metadata that the webhook handler reads on fulfillment. Because both live in the same service, the tier configuration is shared:
const AUDIT_PRICE_CENTS = 19900; // $199
const COMPLIANCE_PRICE_CENTS = 49900; // $499
const REDTEAM_PRICE_CENTS = 240000; // $2,400
function getTierConfig(tier) {
if (tier === 'redteam') return {
id: 'redteam', priceCents: REDTEAM_PRICE_CENTS,
name: 'Red-Team Audit',
description: 'Automated scan plus manual red-team review'
};
if (tier === 'compliance') return {
id: 'compliance', priceCents: COMPLIANCE_PRICE_CENTS,
name: 'Compliance Audit',
description: 'Compliance matrix with branded PDF report'
};
return {
id: 'automated', priceCents: AUDIT_PRICE_CENTS,
name: 'Automated Audit',
description: 'Security scan with branded PDF report delivered by email'
};
}
The webhook handler dispatches to the correct fulfillment function based on product metadata:
async function handleStripeWebhook(req, res) {
const rawBody = await collectRawBody(req);
const sig = req.headers['stripe-signature'];
let event;
try {
const stripe = getStripe();
event = stripe.webhooks.constructEvent(rawBody, sig, STRIPE_WEBHOOK_SECRET);
} catch (err) {
sendJSON(res, 400, { error: 'Invalid signature.' });
return;
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const meta = session.metadata || {};
// Respond immediately — fulfill asynchronously
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: true }));
setImmediate(() => {
if (normalizeTier(meta.tier) === 'redteam') {
fulfillRedteamAudit(meta, session.id);
} else {
fulfillAutomatedAudit(meta, session.id);
}
});
return;
}
sendJSON(res, 200, { received: true });
}
Two critical patterns here. First: respond to Stripe before fulfilling. Stripe expects a 200 within seconds; the actual PDF generation and email delivery can take two minutes. setImmediate defers the heavy work. Second: fulfillment failures trigger operator alerts, not customer errors. If the PDF generation fails, the customer already got their 200. We get an alert with enough context to fulfill manually.
The Notification Fan-Out
When a lead arrives — whether from a form submission, a prompt audit follow-up, or a paid checkout — the same fan-out fires:
setImmediate(() => {
sendToNotificationRouter(lead);
sendIPCNotification(lead);
emitEvent(lead);
});
Three channels, one call site. The notification router handles email and Telegram. The IPC notification goes to the local process bus. The event emission feeds analytics. If we had five services, each would need its own notification integration — or they'd all need to call a shared notification API, adding latency and another failure mode to every request path.
Results
This single service has been running in production for over three months. The numbers:
- 1,638 lines of JavaScript — no framework, no dependencies beyond Stripe's SDK
- 10 routes covering contact forms, checkout flows, webhook handling, analytics tracking, CRM API, and a public scoring tool
- One port (behind nginx) serving the entire customer-facing API surface
- One systemd unit to manage —
systemctl restart contact-formand everything is back - Zero missed webhooks since consolidation — the previous multi-service setup dropped 3 in its first month due to cascading restarts
- Sub-100ms response times on all synchronous routes (checkout creation, form submission, scoring proxy)
- 5 requests per IP per hour rate limit applied uniformly across all endpoints — no per-service configuration drift
The operational improvement was immediate. Before consolidation, a routine VPS restart meant checking five services came back up correctly. Now we check one. Our uptime monitor pings /health and gets back a manifest of every configured route and integration status:
{
"status": "ok",
"service": "contact-form",
"uptime": 847293.4,
"routes": ["/submit", "/audit-request", "/track",
"/prompt-audit-score", "/audit-checkout",
"/stripe-webhook", "/consulting-leads"],
"stripe_configured": true,
"scoring_configured": true
}
One health check tells us whether Stripe keys are loaded, whether the scoring backend is reachable, and whether the CRM API is configured. With five services, that was five health checks with five different response schemas.
Adapting This for Your System
This pattern works when your public-facing endpoints share more infrastructure than domain logic. Ask yourself:
Do all your customer-facing routes need the same rate limiting? If yes, a single gateway avoids duplicating or externalizing that logic.
Do all routes need the same spam/abuse detection? Our injection patterns protect form submissions and scoring requests identically. In separate services, we'd have to maintain that list in multiple places.
Do all routes share notification infrastructure? Every lead — regardless of source — fans out to the same three channels. One integration point beats five.
Is your team small enough that operational simplicity beats theoretical separation? If restarting one service restarts your entire public API, and that's acceptable because your public API is one concern, consolidate.
Where this pattern doesn't work: if your routes have drastically different resource profiles (one is CPU-heavy, one is I/O-heavy) and you need to scale them independently. Or if different teams own different endpoints and need independent deploy cycles. Our scoring proxy calls an internal service for heavy computation — the gateway itself stays lightweight.
The implementation path is straightforward:
- Start with your highest-traffic public endpoint as the base service
- Add shared concerns first: CORS, rate limiting, body parsing, spam detection
- Migrate routes one at a time, keeping the old services running until you've verified
- Use
setImmediatefor anything that doesn't need to block the response - Keep internal services internal — the gateway proxies to them, they never face the public
Conclusion
The microservice instinct says every concern gets its own service. But "concern" is context-dependent. For a small team running a customer-facing API on a single host, the concern isn't "checkout vs. contact form vs. analytics." The concern is "everything a customer touches." That's one service.
Our 1,638-line gateway handles the full customer lifecycle — from first form submission through payment to automated report delivery — in a single process with zero framework dependencies. It's the most reliable service we run, precisely because it's the simplest to operate.
Not every service wants to be a microservice. Sometimes the right architecture is a well-organized monolith with clear boundaries to the internal services that do the heavy lifting.
Need help building AI agent systems or designing multi-agent architectures? Ledd Consulting specializes in autonomous workflow design and agent orchestration for enterprise teams.