DEV Community

Cover image for Carific.ai: Breaking the Type Hammer - Domain Model Architecture for Live PDF Preview
Abdullah Jan
Abdullah Jan

Posted on

Carific.ai: Breaking the Type Hammer - Domain Model Architecture for Live PDF Preview

This is my fifth dev.to post. Previous posts: Auth System, AI Resume Analyzer, Structured AI Output, and Profile Editor Type Safety.

const liveProfile = mergeFormValuesWithProfile(
  profile,
  values
) as unknown as FullProfile;
Enter fullscreen mode Exit fullscreen mode

I stared at that line. as unknown as FullProfile. The "type hammer."

It worked. The PDF preview updated when users typed. But TypeScript had given up. I was lying to the compiler, and deep down, I knew this would bite me.

This is the story of how I refactored a working feature into something actually maintainable - introducing a Domain Model that decouples my PDF templates from both Prisma and TanStack Form. And a code review that taught me about React 19's stricter rules.


The Stack

Package Version Purpose
Next.js 16.0.10 App framework
React 19.2.3 UI library
TanStack Form 1.27.1 Type-safe form state
Zod 4.1.13 Schema validation & type source
@react-pdf/renderer 4.3.1 Live PDF generation
use-debounce 10.0.6 Debounce for performance

Chapter 1: The Problem with Coupling

The Mess I Had

My live PDF preview worked, but the architecture was fragile. The ResumeTemplate component expected a FullProfile - a type derived from Prisma:

// ❌ Tightly coupled to database structure
type FullProfile = NonNullable<Awaited<ReturnType<typeof getFullProfile>>>;

interface ResumeTemplateProps {
  profile: FullProfile;
}
Enter fullscreen mode Exit fullscreen mode

This type included database metadata: id, createdAt, updatedAt, profileId. My templates didn't need any of that. But because the type demanded it, I had to fake it:

// ❌ The type hammer in action
const liveProfile = {
  ...profile,
  ...values,
  workExperiences: values.workExperiences.map((exp) => ({
    ...exp,
    startDate: exp.startDate ? new Date(exp.startDate) : null,
    endDate: exp.endDate ? new Date(exp.endDate) : null,
  })),
  // ... repeat for 6 more sections
} as unknown as FullProfile;
Enter fullscreen mode Exit fullscreen mode

That as unknown as FullProfile was a red flag. I was telling TypeScript, "Trust me, this is a FullProfile" - even though it wasn't. Missing fields, wrong types, runtime crashes waiting to happen.

Why This Matters

  1. No type safety - TypeScript couldn't catch missing or wrong fields
  2. Tight coupling - Templates knew about database structure
  3. Scaling nightmare - Adding new templates meant copying the same casting logic
  4. Maintenance hell - Change the database schema, and who knows what breaks

Chapter 2: The Domain Model Solution

The Insight

The PDF template doesn't care where data comes from. It just needs to know:

  • What's the person's name?
  • What jobs did they have?
  • What skills do they have?

That's it. No id, no createdAt, no profileId. Just content.

Creating the Domain Model

I created a new file: lib/types/resume.ts. This defines what a "Resume" actually is in my application:

// lib/types/resume.ts

import { z } from "zod/v4";

// Define the schema FIRST
export const ResumeDataSchema = z.object({
  displayName: z.string(),
  headline: z.string().nullable(),
  email: z.string().nullable(),
  phone: z.string().nullable(),
  website: z.string().nullable(),
  location: z.string().nullable(),
  bio: z.string().nullable(),

  socialLinks: z.array(
    z.object({
      platform: z.string(),
      url: z.string(),
      label: z.string().nullable(),
    })
  ),

  workExperiences: z.array(
    z.object({
      company: z.string(),
      position: z.string(),
      location: z.string().nullable(),
      startDate: z.date().nullable(),
      endDate: z.date().nullable(),
      current: z.boolean(),
      bullets: z.array(z.string()),
    })
  ),

  // ... educations, projects, skills, etc.
});

// Derive the type FROM the schema
export type ResumeData = z.infer<typeof ResumeDataSchema>;
Enter fullscreen mode Exit fullscreen mode

Key insight: No id fields. No database metadata. Just the content that appears on a resume.

The Transformation Layer

Now I needed functions to convert data sources to this domain model:

// lib/profile-transformation.ts

/**
 * Database Profile → Resume Domain Data
 */
export function profileToResume(profile: FullProfile): ResumeData {
  return {
    displayName: profile.displayName || "",
    headline: profile.headline,
    email: profile.email,
    // ... map all fields, stripping database metadata
    workExperiences: profile.workExperiences.map((exp) => ({
      company: exp.company,
      position: exp.position,
      // Notice: no id, no createdAt, no updatedAt
      startDate: exp.startDate,
      endDate: exp.endDate,
      current: exp.current,
      bullets: exp.bullets,
    })),
  };
}

/**
 * Form Values → Resume Domain Data
 */
export function formToResume(formValues: ProfileFormValues): ResumeData {
  return {
    displayName: formValues.displayName,
    headline: formValues.headline || null,
    // ... map all fields, converting strings to Dates
    workExperiences: formValues.workExperiences.map((exp) => ({
      company: exp.company,
      position: exp.position,
      startDate: exp.startDate ? new Date(exp.startDate) : null,
      endDate: exp.endDate ? new Date(exp.endDate) : null,
      current: exp.current,
      bullets: exp.bullets,
    })),
  };
}
Enter fullscreen mode Exit fullscreen mode

Two data sources. One common output. No type hammers.

Refactoring the Template

Now the template only cares about ResumeData:

// components/pdf/resume-template.tsx

import type { ResumeData } from "@/lib/types/resume";

interface ResumeTemplateProps {
  data: ResumeData;  // ✅ Clean domain type, not database type
}

export function ResumeTemplate({ data }: ResumeTemplateProps) {
  return (
    <Document>
      <Page size="A4" style={styles.page}>
        <Text style={styles.name}>{data.displayName}</Text>
        {data.workExperiences.map((exp, index) => (
          <View key={index} style={styles.experienceItem}>
            {/* Notice: key={index} because ResumeData has no IDs */}
            <Text>{exp.position} at {exp.company}</Text>
          </View>
        ))}
      </Page>
    </Document>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why key={index}? The domain model doesn't have IDs because IDs are a database concern. In a PDF document (which is static and remounts on every change), index-based keys are perfectly safe.

The Profile Editor Update

Now the live preview is beautifully simple:

// components/profile-editor/profile-editor.tsx

<form.Subscribe selector={(state) => [state.values]}>
  {([values]) => {
    const resumeData = formToResume(values);  // ✅ Clean transformation
    return <PDFPreview profile={resumeData} />;
  }}
</form.Subscribe>
Enter fullscreen mode Exit fullscreen mode

No more as unknown as FullProfile. TypeScript is happy. I'm happy.


Chapter 3: The Three-Layer Architecture

Here's what I ended up with:

┌─────────────────────────────────────────────────────────────┐
│                       DATA SOURCES                          │
├─────────────────────────────┬───────────────────────────────┤
│     Database (Prisma)       │      Form (TanStack)          │
│     FullProfile type        │      ProfileFormValues type   │
│     Has: id, createdAt...   │      Has: string dates        │
└──────────────┬──────────────┴───────────────┬───────────────┘
               │                              │
               ▼                              ▼
        profileToResume()              formToResume()
               │                              │
               └──────────────┬───────────────┘
                              ▼
               ┌──────────────────────────────┐
               │        DOMAIN MODEL          │
               │         ResumeData           │
               │   No IDs, no DB metadata     │
               │   Just content               │
               └──────────────┬───────────────┘
                              │
                              ▼
               ┌──────────────────────────────┐
               │         TEMPLATES            │
               │      ResumeTemplate          │
               │      FutureTemplate1         │
               │      FutureTemplate2         │
               └──────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Add 10 new templates without touching Prisma or form code
  • Change database schema without breaking templates
  • Form validation and template rendering are completely decoupled
  • TypeScript catches errors at compile time, not runtime

Chapter 4: The Code Review That Almost Broke Everything

The Performance Suggestion

During code review, I got feedback about a performance concern in the PDF viewer:

"Using JSON.stringify on the entire resume data object can be expensive. Consider using a revision counter instead."

The suggestion:

// Code review suggestion
const revisionRef = useRef(0);

useEffect(() => {
  revisionRef.current += 1;
}, [debouncedData]);

const viewerKey = `viewer-${revisionRef.current}`;
Enter fullscreen mode Exit fullscreen mode

Smart thinking! JSON.stringify on every render does seem wasteful. I merged it.

React 19 Said No

Immediately hit this error:

Error: Cannot access refs during render

React refs are values that are not needed for rendering.
Refs should only be accessed outside of render...
Enter fullscreen mode Exit fullscreen mode

React 19 enforces stricter rules. Reading revisionRef.current during render is now forbidden.

The Fix That Didn't Work

I tried moving to state:

const [revision, setRevision] = useState(0);

useEffect(() => {
  setRevision((r) => r + 1);
}, [debouncedData]);

const viewerKey = `viewer-${revision}`;
Enter fullscreen mode Exit fullscreen mode

TypeScript was happy. React was happy. But the PDF crashed:

TypeError: Eo is not a function
    at PDFViewerClient
Enter fullscreen mode Exit fullscreen mode

Why? The useEffect runs after render. So on the first render after data changes:

  1. Data changes
  2. Component renders with OLD key
  3. @react-pdf/renderer tries to update with new data
  4. 💥 Internal reconciler crashes
  5. THEN useEffect runs and updates the key
  6. Too late

The Lesson: Correctness Over Micro-Optimization

I reverted to JSON.stringify:

// components/pdf/pdf-viewer-client.tsx

export function PDFViewerClient({ data }: PDFViewerClientProps) {
  const [debouncedData] = useDebounce(data, 500);

  const document = useMemo(
    () => <ResumeTemplate data={debouncedData} />,
    [debouncedData]
  );

  // Note: JSON.stringify on resume data (~50KB max) is acceptable;
  // more complex hashing would add unnecessary complexity.
  const viewerKey = JSON.stringify(debouncedData);

  return (
    <PDFViewer key={viewerKey} style={styles.viewer}>
      {document}
    </PDFViewer>
  );
}
Enter fullscreen mode Exit fullscreen mode

For a resume-sized object (~10-50KB), JSON.stringify takes microseconds. The performance "problem" didn't exist. But the bugs from "optimizing" it did.

Sometimes the "slower" solution is the right one.


Chapter 5: Other Code Review Wins

Not all the suggestions broke things. Some were genuinely good:

1. Redundant new Date() Wrapper

// ❌ Before - redundant
export function formatDateForInput(date: Date | null): string {
  if (!date) return "";
  return new Date(date).toISOString().split("T")[0];
}

// ✅ After - simplified
export function formatDateForInput(date: Date | null): string {
  if (!date) return "";
  return date.toISOString().split("T")[0];
}
Enter fullscreen mode Exit fullscreen mode

TypeScript already guarantees date is a Date. No need to wrap it again.

2. Explicit Defensive Date Handling

// ❌ Before - implicit defense
function formatDate(date: Date | null): string {
  if (!date) return "";
  const d = new Date(date);
  if (isNaN(d.getTime())) return "";
  return new Intl.DateTimeFormat("en-US", {
    month: "short",
    year: "numeric",
  }).format(d);
}

// ✅ After - explicit defense with comment
function formatDate(date: Date | null): string {
  if (!date) return "";
  // Defensive: handle string dates from API/JSON that may bypass type system at runtime
  const d = date instanceof Date ? date : new Date(date);
  if (isNaN(d.getTime())) return "";
  return new Intl.DateTimeFormat("en-US", {
    month: "short",
    year: "numeric",
  }).format(d);
}
Enter fullscreen mode Exit fullscreen mode

The instanceof check makes the defensive intent explicit. Future developers will know why this code exists.


TL;DR

Problem Solution
Type hammer (as unknown as FullProfile) Domain Model (ResumeData) with transformation functions
Templates coupled to database types Templates only know about ResumeData
Database IDs in PDF template keys Use index - domain model has no IDs
JSON.stringify performance concern Keep it - correctness > micro-optimization
React 19 ref access during render Don't access refs during render, use state with useEffect
Redundant new Date() wrapper Trust TypeScript, remove the wrapper

Key Lessons

1. Domain Models are worth the effort.

Creating a separate type for "what the resume contains" (vs "what Prisma returns") eliminated an entire category of bugs and made the codebase ready for multiple templates.

2. The "as unknown as" pattern is a code smell.

If you're casting through unknown, you've lost type safety. Step back and redesign.

3. React 19 is stricter about refs.

You can't read refs during render anymore. Use state if you need values during render.

4. Performance intuition can be wrong.

JSON.stringify on a small object is fast. Premature optimization introduced real bugs.

5. Code reviews are valuable, even when they're wrong.

The suggestion didn't work, but the discussion led to documenting why we use JSON.stringify and understanding React 19's new rules.

6. Make defensive code explicit.

If you're guarding against runtime type mismatches, add a comment. Future you will thank present you.


What's Next

  • Multiple resume templates - The domain model makes this trivial
  • Template preview gallery - Show all templates side by side
  • AI-powered suggestions - Use the resume analyzer to improve content inline
  • Public portfolio pages - Share resumes via URL

Why Open Source?

Every refactor, every bug fix, every "I can't believe that didn't work" moment - it's all in the repo.

If this post saves you from the "type hammer" pattern or helps you understand domain models, it was worth writing. Architecture decisions are the hardest to learn from tutorials - they need context and mistakes.

The repo: github.com/ImAbdullahJan/carific.ai

If you find this useful, consider starring the repo - it helps others discover the project!

Key files from this post:

  • lib/types/resume.ts - The domain model
  • lib/profile-transformation.ts - Transformation functions
  • components/pdf/resume-template.tsx - Template using domain model
  • components/pdf/pdf-viewer-client.tsx - PDF viewer with debounce

Your Turn

I'd love feedback:

  • On the architecture: Is domain model overkill for your projects? When would you use it?
  • On the post: Did the diagrams help? Too much code?
  • On React 19: Have you hit the stricter ref rules?

Building in public only works if there's a public to build with.


If this post helped you, drop a ❤️. It means more than you know.


Let's connect:

Fifth post of many. See you in the next one.

Top comments (0)