<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Evgenii Timofeev</title>
    <description>The latest articles on DEV Community by Evgenii Timofeev (@eu_ti_f127c5b5d7535b7174f).</description>
    <link>https://dev.to/eu_ti_f127c5b5d7535b7174f</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4102419%2Fd5567118-12e1-4443-add0-48855e37440c.jpg</url>
      <title>DEV Community: Evgenii Timofeev</title>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eu_ti_f127c5b5d7535b7174f"/>
    <language>en</language>
    <item>
      <title>Introducing the Datanika REST API v1</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 31 Aug 2026 14:46:52 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/introducing-the-datanika-rest-api-v1-1e18</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/introducing-the-datanika-rest-api-v1-1e18</guid>
      <description>&lt;h2&gt;
  
  
  Your Pipelines, Programmable
&lt;/h2&gt;

&lt;p&gt;Today we're shipping the Datanika REST API v1 — 36 endpoints that give you full programmatic control over your data pipelines.&lt;/p&gt;

&lt;p&gt;Everything you can do in the UI, you can now do via HTTP: create connections, trigger pipeline runs, query run history, manage schedules, and configure notification channels.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Included
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Full CRUD for Every Resource
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Endpoints&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Connections&lt;/td&gt;
&lt;td&gt;List, Get, Create, Update, Delete, Test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uploads&lt;/td&gt;
&lt;td&gt;List, Get, Create, Update, Delete, Run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipelines&lt;/td&gt;
&lt;td&gt;List, Get, Create, Update, Delete, Run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transformations&lt;/td&gt;
&lt;td&gt;List, Get, Create, Update, Delete, Run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schedules&lt;/td&gt;
&lt;td&gt;List, Get, Create, Update, Delete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs&lt;/td&gt;
&lt;td&gt;List, Get, Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notification Channels&lt;/td&gt;
&lt;td&gt;List, Get, Create, Update, Delete&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Authentication
&lt;/h3&gt;

&lt;p&gt;API keys use Bearer token authentication. Create a key in &lt;strong&gt;Settings &amp;gt; API Keys&lt;/strong&gt;, then include it in your requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer etf_your_key_here"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     https://app.datanika.io/api/v1/connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keys are scoped — you can limit a key to read-only access, run execution only, or specific resource types. See the &lt;a href="https://datanika.io/api/reference/#scopes" rel="noopener noreferrer"&gt;full scope reference&lt;/a&gt; in our docs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rate Limiting
&lt;/h3&gt;

&lt;p&gt;Requests are counted per API key in a fixed 60-second window:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Requests/minute&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Per key, not per organization — two keys get two independent budgets. A per-second burst ceiling runs alongside the per-minute limit; it is the same on every plan, so it isn't a tier dimension.&lt;/p&gt;

&lt;p&gt;Rate limit headers (&lt;code&gt;X-RateLimit-Remaining&lt;/code&gt;, &lt;code&gt;Retry-After&lt;/code&gt;) are included in every response so your integrations can handle throttling gracefully.&lt;/p&gt;

&lt;p&gt;Self-hosted users can configure limits via &lt;code&gt;API_RATE_LIMIT_RPM&lt;/code&gt; and &lt;code&gt;API_RATE_LIMIT_BURST&lt;/code&gt; environment variables.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Corrected 2026-08-31.&lt;/strong&gt; This section originally published a per-plan &lt;strong&gt;burst&lt;/strong&gt; column (5 / 15 / 30 requests per second) and described the window as sliding. Neither was true of the shipped code: the burst ceiling has always been a single setting applied identically to every plan, and the window is fixed rather than sliding. The per-minute numbers are unchanged. See &lt;a href="https://datanika.io/api/reference/#rate-limits" rel="noopener noreferrer"&gt;the API reference&lt;/a&gt; for the current statement.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Interactive Docs
&lt;/h3&gt;

&lt;p&gt;The API ships with an OpenAPI 3.0 spec and interactive Swagger UI at &lt;a href="https://app.datanika.io/api/v1/docs" rel="noopener noreferrer"&gt;&lt;code&gt;/api/v1/docs&lt;/code&gt;&lt;/a&gt;. Try endpoints directly from your browser — no Postman needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CI/CD integration&lt;/strong&gt; — Trigger a pipeline run after your data model tests pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://app.datanika.io/api/v1/pipelines/1/run &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer etf_ci_key"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Custom monitoring&lt;/strong&gt; — Poll run status from your own alerting system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://app.datanika.io/api/v1/runs?status&lt;span class="o"&gt;=&lt;/span&gt;failed &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer etf_monitor_key"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bulk setup&lt;/strong&gt; — Provision pipelines from a shell script or your infra automation. Every write endpoint is exposed and supports the &lt;code&gt;Idempotency-Key&lt;/code&gt; header, so re-running the script is safe. POST each resource in turn — connections first, then the upload or pipeline that references them, then a schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://app.datanika.io/api/v1/connections &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer etf_admin_key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Idempotency-Key: bootstrap-2026-04-14-src"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-d&lt;/span&gt; @source-postgres.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Available write endpoints: &lt;code&gt;/connections&lt;/code&gt;, &lt;code&gt;/uploads&lt;/code&gt;, &lt;code&gt;/pipelines&lt;/code&gt;, &lt;code&gt;/transformations&lt;/code&gt;, &lt;code&gt;/schedules&lt;/code&gt;, &lt;code&gt;/notifications/channels&lt;/code&gt;. Keep the JSON bodies in version control next to your infra code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduled reporting&lt;/strong&gt; — Fetch run history for weekly reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://app.datanika.io/api/v1/runs?target_type=pipeline&amp;amp;limit=100"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer etf_report_key"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;We're working on webhook triggers (run your pipeline when an external event fires) and a CLI tool built on top of the API. If you have feedback or feature requests, &lt;a href="https://github.com/datanika-io/datanika-core/issues" rel="noopener noreferrer"&gt;open an issue on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The API is available now on all plans, including Free. &lt;a href="https://app.datanika.io" rel="noopener noreferrer"&gt;Create an API key&lt;/a&gt; and start automating.&lt;/p&gt;

&lt;p&gt;Full documentation: &lt;a href="https://datanika.io/api/reference/" rel="noopener noreferrer"&gt;datanika.io/api/reference&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/api/keys/" rel="noopener noreferrer"&gt;API Keys&lt;/a&gt; — create and manage API keys&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;Self-Hosting Guide&lt;/a&gt; — the API works on self-hosted deployments too&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/connectors/" rel="noopener noreferrer"&gt;36 Connectors&lt;/a&gt; — everything you can manage via the API&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/templates/" rel="noopener noreferrer"&gt;Pipeline Templates&lt;/a&gt; — prefilled starting points your API-driven workflows can clone: &lt;a href="https://datanika.io/templates/stripe-to-postgres/" rel="noopener noreferrer"&gt;Stripe → Postgres&lt;/a&gt;, &lt;a href="https://datanika.io/templates/postgres-to-bigquery/" rel="noopener noreferrer"&gt;Postgres → BigQuery&lt;/a&gt;, &lt;a href="https://datanika.io/templates/csv-to-duckdb/" rel="noopener noreferrer"&gt;CSV → DuckDB&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>rest</category>
      <category>webdev</category>
      <category>python</category>
    </item>
    <item>
      <title>I Added 32 Connectors to My Data Platform. Most Took Less Than a Day Each.</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 31 Aug 2026 14:41:51 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/i-added-32-connectors-to-my-data-platform-most-took-less-than-a-day-each-4c3o</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/i-added-32-connectors-to-my-data-platform-most-took-less-than-a-day-each-4c3o</guid>
      <description>&lt;p&gt;When I started, &lt;a href="https://datanika.io" rel="noopener noreferrer"&gt;Datanika&lt;/a&gt; had 4 database connectors. Now it has 32: 27 sources, 11 destinations, and 11 dbt adapters. The secret is that I'm not writing connectors from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;For databases (Postgres, MySQL, MSSQL, ClickHouse, DuckDB, MongoDB), there's a &lt;a href="https://dlthub.com" rel="noopener noreferrer"&gt;dlt&lt;/a&gt; source adapter per type. Each one maps connection credentials to dlt's config format and handles type-specific quirks. The actual extraction is dlt's job — I'm just wiring up the UI and storing config in the database.&lt;/p&gt;

&lt;p&gt;For SaaS sources (&lt;a href="https://datanika.io/connectors/stripe/" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt;, &lt;a href="https://datanika.io/connectors/github/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, &lt;a href="https://datanika.io/connectors/hubspot/" rel="noopener noreferrer"&gt;HubSpot&lt;/a&gt;, &lt;a href="https://datanika.io/connectors/salesforce/" rel="noopener noreferrer"&gt;Salesforce&lt;/a&gt;, &lt;a href="https://datanika.io/connectors/shopify/" rel="noopener noreferrer"&gt;Shopify&lt;/a&gt;, &lt;a href="https://datanika.io/connectors/jira/" rel="noopener noreferrer"&gt;Jira&lt;/a&gt;, &lt;a href="https://datanika.io/connectors/slack/" rel="noopener noreferrer"&gt;Slack&lt;/a&gt;, Google Analytics, Facebook Ads, and more), I use dlt's REST API source or its verified sources. Each connector is really just a config: base URL, auth method, and a list of default endpoints. Stripe, for example, ships with 6 endpoints pre-configured (customers, charges, invoices, subscriptions, products, prices).&lt;/p&gt;

&lt;p&gt;For destinations (&lt;a href="https://datanika.io/connectors/bigquery/" rel="noopener noreferrer"&gt;BigQuery&lt;/a&gt;, &lt;a href="https://datanika.io/connectors/snowflake/" rel="noopener noreferrer"&gt;Snowflake&lt;/a&gt;, &lt;a href="https://datanika.io/connectors/redshift/" rel="noopener noreferrer"&gt;Redshift&lt;/a&gt;, &lt;a href="https://datanika.io/connectors/databricks/" rel="noopener noreferrer"&gt;Databricks&lt;/a&gt;, Azure Synapse, ClickHouse, DuckDB), it's dlt destination objects plus matching dbt adapters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hard Ones
&lt;/h2&gt;

&lt;p&gt;The hardest connectors weren't the obvious ones. Two stand out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ClickHouse&lt;/strong&gt; has three different table engine modes — standalone MergeTree, clustered ReplicatedMergeTree, and ClickHouse Cloud. Each requires different DDL and a different mental model for replication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MongoDB&lt;/strong&gt; needed a custom dlt source because the standard one didn't handle nested documents the way I wanted. Flattening nested arrays into relational tables for analytics is more opinionated than it sounds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else was mostly: read the API docs, fill in the dlt config schema, write 5–10 tests, ship it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phased Rollout
&lt;/h2&gt;

&lt;p&gt;I rolled the SaaS connectors out in three phases over two days, not all at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phase A&lt;/strong&gt;: 4 connectors (the highest-demand ones)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase B&lt;/strong&gt;: 7 more&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase C&lt;/strong&gt;: 7 more&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each phase was a single commit with tests. This kept reviewable diffs small and let me catch architecture issues early. If phase A broke something subtle in the connection model, phase B would have surfaced it before I shipped 14 more connectors on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lesson
&lt;/h2&gt;

&lt;p&gt;Pick tools that are themselves extensible. dlt's plugin architecture meant "add a connector" was mostly config, not code. If I'd built extraction from scratch, I'd still be on connector #4.&lt;/p&gt;

&lt;p&gt;This is also why Datanika uses &lt;a href="https://www.getdbt.com" rel="noopener noreferrer"&gt;dbt-core&lt;/a&gt; for transformations rather than rolling our own SQL execution engine. The ecosystem has solved problems I don't need to re-solve — packages, tests, snapshots, materializations, lineage. Building on it gives users 100% dbt compatibility for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I'm thinking about adding Webhooks (push-based ingestion) and a generic "S3-compatible" source that works with R2, MinIO, Backblaze, etc. — anything that speaks the S3 API.&lt;/p&gt;

&lt;p&gt;What connectors would &lt;em&gt;you&lt;/em&gt; want to see next? Open an &lt;a href="https://github.com/datanika-io/datanika-core/issues" rel="noopener noreferrer"&gt;issue on GitHub&lt;/a&gt; or try the existing 32 at &lt;a href="https://datanika.io/connectors/" rel="noopener noreferrer"&gt;datanika.io/connectors&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datanika.io/connectors/" rel="noopener noreferrer"&gt;View all 36 connectors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datanika.io/blog/postgresql-to-bigquery/" rel="noopener noreferrer"&gt;PostgreSQL → BigQuery in 5 minutes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;Self-host with Docker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/datanika-io/datanika-core" rel="noopener noreferrer"&gt;Star on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dataengineering</category>
      <category>python</category>
      <category>opensource</category>
      <category>api</category>
    </item>
    <item>
      <title>I Built an ETL Platform Solo. 18 Phases, 1,400+ Tests, One Month.</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 31 Aug 2026 14:36:49 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/i-built-an-etl-platform-solo-18-phases-1400-tests-one-month-5e49</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/i-built-an-etl-platform-solo-18-phases-1400-tests-one-month-5e49</guid>
      <description>&lt;p&gt;Most ETL tools are built by teams of 20+. I built one by myself in about a month.&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://datanika.io" rel="noopener noreferrer"&gt;Datanika&lt;/a&gt; — a platform for managing data pipelines. Extract with &lt;a href="https://dlthub.com" rel="noopener noreferrer"&gt;dlt&lt;/a&gt;, transform with &lt;a href="https://www.getdbt.com" rel="noopener noreferrer"&gt;dbt-core&lt;/a&gt;, orchestrate with Celery and APScheduler, all wrapped in a Python UI using &lt;a href="https://reflex.dev" rel="noopener noreferrer"&gt;Reflex&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope, Not Speed
&lt;/h2&gt;

&lt;p&gt;The key decision was to &lt;em&gt;not&lt;/em&gt; build everything at once. I broke the entire project into 18 phases, each small enough to finish in 1–3 days:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1&lt;/strong&gt;: Models and auth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 2&lt;/strong&gt;: Connections and pipeline config&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 3&lt;/strong&gt;: dbt integration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 4&lt;/strong&gt;: Scheduling and dependency DAGs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 5&lt;/strong&gt;: Polish, dashboard, RBAC&lt;/li&gt;
&lt;li&gt;... and so on&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each phase had a clear deliverable and a short feedback loop. If I couldn't finish a phase in three days, the scope was wrong, not the timeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  TDD Without Compromises
&lt;/h2&gt;

&lt;p&gt;Each phase followed strict TDD: failing test first, then implementation, then commit. No exceptions. Even when the feature was "obviously going to work."&lt;/p&gt;

&lt;p&gt;That discipline is the only reason I didn't drown in regressions. By the end, I had &lt;strong&gt;1,424 tests&lt;/strong&gt; across 73 test files — covering models, services, tasks, security, end-to-end flows, and even multi-tenant isolation. (Plus 73 dedicated security tests for SQL injection, XSS, path traversal, auth attacks, and tenant boundary checks.)&lt;/p&gt;

&lt;p&gt;The tests run in under a minute on SQLite. CI catches regressions before they reach a PR review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layered Architecture Saves You
&lt;/h2&gt;

&lt;p&gt;The hardest part wasn't any single feature. It was keeping the architecture clean enough that phase 18 didn't break phase 1.&lt;/p&gt;

&lt;p&gt;Layered architecture helped — every change had to fit one of these layers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;models/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ORM definitions&lt;/td&gt;
&lt;td&gt;No business logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;services/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Business logic&lt;/td&gt;
&lt;td&gt;All state changes happen here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tasks/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Celery wrappers&lt;/td&gt;
&lt;td&gt;Thin shells around services&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ui/state/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reactive state classes&lt;/td&gt;
&lt;td&gt;Calls services and tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ui/pages/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reflex page functions&lt;/td&gt;
&lt;td&gt;Returns components, no logic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Strict boundaries meant I could add features in phase 18 without rewriting foundations from phase 1. Need to add a new pipeline type? Add a model, a service method, a task wrapper, a state action. Done.&lt;/p&gt;

&lt;p&gt;I write more about why I picked a &lt;a href="https://datanika.io/blog/multitenancy-mistake/" rel="noopener noreferrer"&gt;public schema with &lt;code&gt;org_id&lt;/code&gt; filtering&lt;/a&gt; over the textbook schema-per-tenant approach in another post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Open-Core Plugin System
&lt;/h2&gt;

&lt;p&gt;One non-obvious win: the &lt;a href="https://datanika.io/docs/architecture/" rel="noopener noreferrer"&gt;hooks system&lt;/a&gt;. Core emits events like &lt;code&gt;connection.before_create&lt;/code&gt; and &lt;code&gt;run.upload_completed&lt;/code&gt;. The cloud plugin (billing, quotas, metering) subscribes to those events. Core never imports the plugin — the plugin only loads when &lt;code&gt;DATANIKA_EDITION=cloud&lt;/code&gt; is set.&lt;/p&gt;

&lt;p&gt;This means the open-source core has zero billing code. It's not crippled — it's just &lt;em&gt;complete without billing&lt;/em&gt;. Self-hosted users get every feature except the Paddle integration, which they don't need anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scope control matters more than speed.&lt;/strong&gt; A 45-step plan with clear boundaries beats "let me just build the whole thing." Most solo projects fail at scope — they either expand until burnout or never ship anything that works end-to-end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick boring tools.&lt;/strong&gt; Postgres, Celery, Redis, dlt, dbt-core — none of these are exciting in 2026. All of them work. I'd rather spend my creativity on the product than on figuring out which obscure framework to bet on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests as a forcing function.&lt;/strong&gt; TDD isn't about test coverage. It's about being forced to articulate what "done" means before you write the code. That alone catches 80% of design mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  See For Yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datanika.io/docs/architecture/" rel="noopener noreferrer"&gt;Architecture overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;Self-host with Docker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datanika.io/connectors/" rel="noopener noreferrer"&gt;36 connectors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/datanika-io/datanika-core" rel="noopener noreferrer"&gt;Star on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://app.datanika.io" rel="noopener noreferrer"&gt;Try the cloud version free&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building this in public. Feedback welcome — open an &lt;a href="https://github.com/datanika-io/datanika-core/issues" rel="noopener noreferrer"&gt;issue on GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>python</category>
      <category>tdd</category>
      <category>startup</category>
    </item>
    <item>
      <title>How I Split Open-Source Core From Paid Cloud Without Forking the Codebase</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 31 Aug 2026 14:31:47 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/how-i-split-open-source-core-from-paid-cloud-without-forking-the-codebase-k17</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/how-i-split-open-source-core-from-paid-cloud-without-forking-the-codebase-k17</guid>
      <description>&lt;p&gt;Datanika's core is open source under AGPL-3.0. The billing layer — subscription management, quota enforcement, Paddle integration, usage metering — lives in a separate repo called &lt;code&gt;datanika-cloud&lt;/code&gt; and is proprietary. &lt;strong&gt;Core never imports cloud. Cloud imports core, but only to subscribe to events.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There's no fork. There are no feature flags. There's no &lt;code&gt;if ENTERPRISE_EDITION:&lt;/code&gt; branching inside core functions. Just a generic hooks system and a plugin that registers handlers at startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Conditional Branching
&lt;/h2&gt;

&lt;p&gt;Most open-core projects get the boundary wrong. They have one codebase with enterprise features gated behind runtime checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_enterprise&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="nf"&gt;check_quota&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ← this line is the problem
&lt;/span&gt;    &lt;span class="c1"&gt;# ... actual business logic
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That check creates a permanent coupling between core and the paid tier. Once you have one of those lines, you need them everywhere. Core now has to know about plans, quotas, and billing logic. The "open-source" version is cluttered with enterprise hooks that are dead code for most users. Contributors see billing logic in files they're trying to fix bugs in.&lt;/p&gt;

&lt;p&gt;I didn't want that. I wanted a core that doesn't know billing exists, and a billing layer that watches from the outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: A Generic Hook System
&lt;/h2&gt;

&lt;p&gt;The whole boundary is ~30 lines of Python. Core ships a generic event emitter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# datanika/hooks.py
&lt;/span&gt;&lt;span class="n"&gt;_handlers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;_handlers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_handlers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
        &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Services in core call &lt;code&gt;emit()&lt;/code&gt; at specific checkpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# datanika/services/connection.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datanika.hooks&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;emit&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection.before_create&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ← anyone listening?
&lt;/span&gt;    &lt;span class="c1"&gt;# ... actual business logic
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core doesn't know or care what &lt;code&gt;connection.before_create&lt;/code&gt; means to downstream code. If no handler is registered, &lt;code&gt;emit()&lt;/code&gt; is a no-op. The service moves on and creates the connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Plugin Side
&lt;/h2&gt;

&lt;p&gt;The cloud plugin has one entry point — &lt;code&gt;init_cloud(app)&lt;/code&gt; — called once at startup when the environment variable &lt;code&gt;DATANIKA_EDITION=cloud&lt;/code&gt; is set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# datanika-cloud/datanika_cloud/plugin.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datanika.hooks&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datanika_cloud.billing.quota&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;check_connection_quota&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_schedule_quota&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;check_seat_quota&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_run_quota&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_sso_quota&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datanika_cloud.billing.meter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;handle_model_runs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handle_upload_runs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handle_transformation_run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;init_cloud&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Quota enforcement — reactive
&lt;/span&gt;    &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection.before_create&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_connection_quota&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schedule.before_create&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_schedule_quota&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;membership.before_create&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_seat_quota&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run.before_execute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_run_quota&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sso_config.before_create&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_sso_quota&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Usage metering — reactive
&lt;/span&gt;    &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run.models_completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handle_model_runs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run.upload_completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handle_upload_runs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run.transformation_completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handle_transformation_run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight lines register eight hook handlers. When a user tries to create a connection, the &lt;code&gt;check_connection_quota&lt;/code&gt; handler gets called with the org ID. It looks up the org's plan, checks the connection count, and raises &lt;code&gt;QuotaExceededError&lt;/code&gt; if they're at the limit. Core sees the exception propagate, rolls back, and returns an error to the user.&lt;/p&gt;

&lt;p&gt;Core has no idea a plugin exists. It just called &lt;code&gt;emit("connection.before_create", org_id=42)&lt;/code&gt; and got an exception back. It handles the exception the same way it would handle any other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Additive Side
&lt;/h2&gt;

&lt;p&gt;I need to be honest about one thing: the plugin doesn't &lt;em&gt;only&lt;/em&gt; react. It also adds things the core never had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;init_cloud&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# ... hook handlers above ...
&lt;/span&gt;
    &lt;span class="c1"&gt;# Merge billing translations into core i18n cache
&lt;/span&gt;    &lt;span class="nf"&gt;register_translations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CLOUD_TRANSLATIONS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Add a billing link to the sidebar
&lt;/span&gt;    &lt;span class="n"&gt;extra_sidebar_links&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nav.billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/settings/billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credit-card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="c1"&gt;# Register a Reflex page at /settings/billing
&lt;/span&gt;    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;billing_page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/settings/billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;

    &lt;span class="c1"&gt;# Register a Paddle webhook route
&lt;/span&gt;    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhook_route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The i18n translations, the sidebar link, the billing page, and the webhook route are all additive — they're new surfaces, not modifications to existing core behavior. The core exposes extension points (&lt;code&gt;extra_sidebar_links&lt;/code&gt;, &lt;code&gt;app._api.routes&lt;/code&gt;, &lt;code&gt;app.add_page&lt;/code&gt;) that any plugin could use. Cloud happens to be the only one using them today.&lt;/p&gt;

&lt;p&gt;The mental model is: &lt;strong&gt;core defines what's possible; plugins choose what to activate&lt;/strong&gt;. Quotas are a kind of activation — "hey, watch this event and block it if the org is over limit". UI additions are another kind — "hey, put this link in the sidebar". Neither requires modifying core code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-Hosted Gets Everything
&lt;/h2&gt;

&lt;p&gt;Because the plugin only loads when &lt;code&gt;DATANIKA_EDITION=cloud&lt;/code&gt; is set, self-hosted users running &lt;code&gt;docker compose up&lt;/code&gt; get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All 36 connectors — PostgreSQL, BigQuery, Stripe, Salesforce, everything&lt;/li&gt;
&lt;li&gt;dbt transformations, tests, snapshots, packages&lt;/li&gt;
&lt;li&gt;The full visual pipeline builder&lt;/li&gt;
&lt;li&gt;Scheduling with cron + dependency DAGs&lt;/li&gt;
&lt;li&gt;Multi-org with RBAC and audit logging&lt;/li&gt;
&lt;li&gt;SSO (SAML + OIDC) — the code is in core, just not gated to a plan&lt;/li&gt;
&lt;li&gt;9 languages&lt;/li&gt;
&lt;li&gt;The REST API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What they don't get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paddle billing overlays&lt;/li&gt;
&lt;li&gt;Plan-based quota enforcement (they have no plans, so no limits)&lt;/li&gt;
&lt;li&gt;Usage metering to a central ledger&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you self-host, nothing is "locked". The quota handlers simply aren't registered, so there's nothing to check against. You get a platform without billing — which is exactly what self-hosted users want.&lt;/p&gt;

&lt;p&gt;There's no "community edition" and "enterprise edition" distinction at the code level. There's only one codebase, and the plugin is either loaded or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Feature Flags?
&lt;/h2&gt;

&lt;p&gt;The obvious alternative is feature flags: one codebase, runtime checks. &lt;code&gt;if settings.billing_enabled: ...&lt;/code&gt; everywhere you'd otherwise hook.&lt;/p&gt;

&lt;p&gt;I considered it. Rejected it because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't separate the license&lt;/strong&gt;. The code is still in the same repo, still compiled into the same binary. If I want the billing logic to be proprietary, feature flags don't help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It couples unrelated concerns&lt;/strong&gt;. Every function that needs a quota check now imports from a billing module. The "open-source" part isn't really open-source — it depends on billing internals to decide whether to run them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It makes testing harder&lt;/strong&gt;. Every test has to mock or disable the billing flag. With a plugin, tests in core never see any billing code at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It leaks into docs&lt;/strong&gt;. The OSS README has to explain feature flags a self-hosted user should never enable. Noise for contributors.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The hook system avoids all four. Core's tests run against a core without any handlers registered. They're testing the real, shipped behavior for OSS users. The plugin has its own test suite that registers handlers and verifies them in isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Rule
&lt;/h2&gt;

&lt;p&gt;Getting the boundary right took me longer than the implementation. The rule I landed on, which I wrote on a sticky note and kept re-reading:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Core defines the events. Cloud chooses which ones matter.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Core must never import from cloud. Cloud may only import from core's public interface — no reaching into private modules. If I find myself wanting to add a new hook &lt;em&gt;because&lt;/em&gt; cloud needs one, that's fine — it means the core probably has an extension point it should have exposed anyway. But I never add hooks that &lt;em&gt;only&lt;/em&gt; cloud uses without also documenting them as general extensibility.&lt;/p&gt;

&lt;p&gt;This means core is genuinely usable by someone who forks it and writes a completely different plugin — for their own billing system, for custom observability, for an internal analytics layer. The hooks are generic, the events are stable, and the plugin pattern is reproducible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in Each Edition
&lt;/h2&gt;

&lt;p&gt;For anyone curious about where the line falls:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Open-core (AGPL)&lt;/th&gt;
&lt;th&gt;Cloud plugin (private)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pipeline builder, scheduling, dbt&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;36 connectors&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSO (SAML + OIDC) code&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notification channels&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REST API v1&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit logging&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paddle checkout overlay&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plan model + Subscription model&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usage ledger + hourly overage sync&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quota enforcement&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Billing settings page&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free / Pro / Enterprise plan records&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice what's &lt;em&gt;not&lt;/em&gt; in the cloud plugin: SSO, notifications, RBAC, RESTful API. Those are in core. They're sometimes associated with "enterprise features" in other products, but Datanika treats them as basic platform functionality — if you want SSO and you're self-hosting, you get SSO. The plugin is purely about &lt;em&gt;commercial&lt;/em&gt; concerns (subscriptions, quotas, metering), not feature gating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Both Ways
&lt;/h2&gt;

&lt;p&gt;If you want to see the architecture in action:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted (plugin off)&lt;/strong&gt;: &lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;docker compose up&lt;/a&gt; — all features, no billing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud (plugin on)&lt;/strong&gt;: &lt;a href="https://app.datanika.io" rel="noopener noreferrer"&gt;app.datanika.io&lt;/a&gt; — same features, plus subscription management&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing page for cloud&lt;/strong&gt;: &lt;a href="https://datanika.io/pricing/" rel="noopener noreferrer"&gt;/pricing&lt;/a&gt; — Free / Pro / Enterprise tiers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if you're building your own open-core product, the one thing I'd recommend is: start with the plugin boundary, not the features. Decide what "core" is going to mean, write down the rule, and don't break it even when it's tempting. Six months in, you'll either have a clean separation or a tangled mess — and the difference is 100% about whether you enforced the rule on day one.&lt;/p&gt;

&lt;p&gt;Would love to hear how others handle open-core monetization. Open an &lt;a href="https://github.com/datanika-io/datanika-core/discussions" rel="noopener noreferrer"&gt;issue or discussion on GitHub&lt;/a&gt; — I'm collecting notes for a follow-up post on the &lt;em&gt;business&lt;/em&gt; side of open-core, not just the technical one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/blog/why-reflex/" rel="noopener noreferrer"&gt;I Wrote a Full SaaS UI Without a Single Line of JavaScript&lt;/a&gt; — the architecture choices that made this kind of boundary possible&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/blog/multitenancy-mistake/" rel="noopener noreferrer"&gt;I Designed Multi-Tenancy Wrong&lt;/a&gt; — the same layering principle applied to the Postgres side&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;Self-Hosting Guide&lt;/a&gt; — run the open-core edition on your own infrastructure&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/pricing/" rel="noopener noreferrer"&gt;Pricing&lt;/a&gt; — what the cloud plugin adds on top&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>saas</category>
      <category>architecture</category>
      <category>python</category>
    </item>
    <item>
      <title>MongoDB `Authentication failed`: You're Authenticating Against the Wrong Database</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 31 Aug 2026 12:58:38 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/mongodb-authentication-failed-youre-authenticating-against-the-wrong-database-2boj</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/mongodb-authentication-failed-youre-authenticating-against-the-wrong-database-2boj</guid>
      <description>&lt;p&gt;You have a MongoDB user. You created it yourself. You can paste the password into &lt;code&gt;mongosh&lt;/code&gt; and get a shell. Then you put the same credentials into an application, a BI tool, or an ELT connector, and get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pymongo.errors.OperationFailure: Authentication failed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The credentials are fine. The problem is that you and MongoDB disagree about &lt;strong&gt;which database the user lives in&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database in the URI is also the auth database
&lt;/h2&gt;

&lt;p&gt;Here is the connection string almost everyone writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mongodb://analytics_ro:s3cret@mongo.internal:27017/production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that as a human and it says: &lt;em&gt;connect to the &lt;code&gt;production&lt;/code&gt; database as &lt;code&gt;analytics_ro&lt;/code&gt;&lt;/em&gt;. Read it as MongoDB does and it says something extra — &lt;em&gt;and look for &lt;code&gt;analytics_ro&lt;/code&gt; **inside&lt;/em&gt;* &lt;code&gt;production&lt;/code&gt;*.&lt;/p&gt;

&lt;p&gt;The database component of a MongoDB URI serves double duty. It names the default database for operations &lt;strong&gt;and&lt;/strong&gt; the authentication database, unless you override the second with &lt;code&gt;authSource&lt;/code&gt;. The rule in the connection-string spec is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;authSource&lt;/code&gt; given → use it.&lt;/li&gt;
&lt;li&gt;Not given, but a database is in the path → &lt;strong&gt;use the path database&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Not given and no database in the path → use &lt;code&gt;admin&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Almost every real connection string has a database in the path. So almost every real connection string silently opts into the middle rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this hits nearly everyone
&lt;/h2&gt;

&lt;p&gt;Because the place your user actually lives is &lt;code&gt;admin&lt;/code&gt;, and you probably didn't choose that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The official MongoDB Docker image creates its user in &lt;code&gt;admin&lt;/code&gt;. That is what &lt;code&gt;MONGO_INITDB_ROOT_USERNAME&lt;/code&gt; does.&lt;/li&gt;
&lt;li&gt;MongoDB Atlas stores database users in &lt;code&gt;admin&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Every managed provider and essentially every "create a read-only user" tutorial starts with &lt;code&gt;use admin&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the default is a mismatch: your user is in &lt;code&gt;admin&lt;/code&gt;, your data is in &lt;code&gt;production&lt;/code&gt;, and your URI told the driver to look for the user in &lt;code&gt;production&lt;/code&gt;. There is no user there. Authentication fails, correctly, and the error message tells you nothing about which database it searched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming it in thirty seconds
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;mongosh&lt;/code&gt; takes the same flag, so you can isolate the variable without touching your application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Fails — looks for the user inside `production`&lt;/span&gt;
mongosh &lt;span class="s2"&gt;"mongodb://analytics_ro:s3cret@mongo.internal:27017/production"&lt;/span&gt;

&lt;span class="c"&gt;# Works — looks for the user in `admin`&lt;/span&gt;
mongosh &lt;span class="s2"&gt;"mongodb://analytics_ro:s3cret@mongo.internal:27017/production?authSource=admin"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the second one connects, you have your answer, and it was never a password problem.&lt;/p&gt;

&lt;p&gt;You can also just ask where the user is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;use&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;db&lt;/code&gt; field on each record is the authentication database. That is the value &lt;code&gt;authSource&lt;/code&gt; needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Append it to the URI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mongodb://analytics_ro:s3cret@mongo.internal:27017/production?authSource&lt;span class="o"&gt;=&lt;/span&gt;admin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or pass it as a parameter, if your driver prefers that shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;MongoClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mongo.internal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;27017&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analytics_ro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3cret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;authSource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# where the user lives
&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;             &lt;span class="c1"&gt;# what you want to read
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what the two lines mean. &lt;code&gt;authSource&lt;/code&gt; is &lt;em&gt;where the user is defined&lt;/em&gt;; the database you select afterwards is &lt;em&gt;what you want to read&lt;/em&gt;. Keeping them straight in your head is most of the battle.&lt;/p&gt;

&lt;p&gt;If you use Atlas, the string it hands you already contains &lt;code&gt;authSource=admin&lt;/code&gt; — which is why Atlas users often never learn this rule until the first time they hand-assemble a URI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bug is so good at hiding
&lt;/h2&gt;

&lt;p&gt;Here is the part that makes it survive code review, CI, and a local test run: &lt;strong&gt;an unauthenticated &lt;code&gt;mongod&lt;/code&gt; is completely unaffected.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Start MongoDB with no &lt;code&gt;--auth&lt;/code&gt;, connect with no credentials, and there is no authentication step to get wrong. Every query works. That is exactly what a developer laptop looks like, and exactly what a throwaway container in a test suite looks like.&lt;/p&gt;

&lt;p&gt;The mismatch only appears against a server that actually enforces authentication — which is to say, only in staging and production, and only after everything has been signed off.&lt;/p&gt;

&lt;p&gt;We know this shape well, because we shipped it. Our own MongoDB connector built &lt;code&gt;mongodb://user:pass@host:port/&amp;lt;target-db&amp;gt;&lt;/code&gt; with no &lt;code&gt;authSource&lt;/code&gt; and therefore could not authenticate against any standard deployment. It went unnoticed for the reason above: the instance it was developed against had no auth enabled. It was one of several connector defects we found and wrote up in &lt;a href="https://datanika.io/blog/green-tests-broken-connectors/" rel="noopener noreferrer"&gt;2,300 Passing Tests and a CSV That Loaded One Row&lt;/a&gt;, and the rule that came out of that audit — &lt;em&gt;a connector is not done until a row has been observed in the destination&lt;/em&gt; — is the one that catches this class.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Datanika handles it now
&lt;/h2&gt;

&lt;p&gt;Datanika's MongoDB connector authenticates against &lt;code&gt;admin&lt;/code&gt; by default, and builds &lt;code&gt;authSource&lt;/code&gt; into the URI accordingly.&lt;/p&gt;

&lt;p&gt;That default is deliberate, and it is the opposite of the previous behaviour rather than a compatible extension of it. Defaulting to the target database would have been the backwards-compatible choice, and it would have meant the connector stayed broken for everyone who didn't already know about a setting nobody had told them about. Defaulting to &lt;code&gt;admin&lt;/code&gt; fixes the configuration almost everyone actually has.&lt;/p&gt;

&lt;p&gt;Both code paths agree on it now. The URI is assembled by one function that Test Connection and the run path both call, so the button's verdict and the run's outcome cannot disagree about the same connection. That was worth fixing on its own: for a while they &lt;em&gt;did&lt;/em&gt; disagree, and in the worst direction — Test Connection reported failure for a configuration whose runs succeeded, telling users their working credentials were broken.&lt;/p&gt;

&lt;p&gt;Full walkthrough in the &lt;a href="https://datanika.io/docs/connectors/mongodb/" rel="noopener noreferrer"&gt;MongoDB setup guide&lt;/a&gt;, and the connector's capabilities and limits are on the &lt;a href="https://datanika.io/connectors/mongodb/" rel="noopener noreferrer"&gt;MongoDB connector page&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Two caveats we owe you, because both are live right now.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. If your user is &lt;em&gt;not&lt;/em&gt; in &lt;code&gt;admin&lt;/code&gt;, the connection form cannot say so.&lt;/strong&gt; The setting exists in the config — it is &lt;code&gt;auth_source&lt;/code&gt; — but the &lt;code&gt;mongodb&lt;/code&gt; form renders only Host, Port, User, Password and Database, so there is no input for it. You can still set it, by ticking &lt;strong&gt;Use raw JSON&lt;/strong&gt; on the connection form and adding the key by hand; but a connection saved that way loses it the next time it is saved from the structured form, and authentication silently reverts to &lt;code&gt;admin&lt;/code&gt;. Tracked as &lt;a href="https://github.com/datanika-io/datanika-core/issues/638" rel="noopener noreferrer"&gt;core#638&lt;/a&gt;. If your user lives inside the database you are reading, keep the connection in raw-JSON mode until that lands. &lt;em&gt;A setting with no surface is not a setting — it is a guess that happens to be right most of the time, and we shipped exactly that.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. If your MongoDB is Atlas, none of this reaches the auth step at all.&lt;/strong&gt; We build every URI as a plain &lt;code&gt;mongodb://&lt;/code&gt; string with no transport options, so the driver negotiates &lt;strong&gt;no TLS&lt;/strong&gt; — not "TLS if the server offers it", none. Atlas requires TLS, so the handshake fails first and &lt;code&gt;authSource&lt;/code&gt; never gets a chance to be wrong. There is no &lt;code&gt;mongodb+srv://&lt;/code&gt; support either, so the seedlist hostname Atlas gives you cannot be entered. The general rule is &lt;em&gt;any server that requires TLS&lt;/em&gt;: Azure Cosmos DB's Mongo API, a self-hosted &lt;code&gt;net.tls.mode: requireTLS&lt;/code&gt;, and Amazon DocumentDB unless its &lt;code&gt;tls&lt;/code&gt; cluster parameter has been explicitly disabled. Tracked as &lt;a href="https://github.com/datanika-io/datanika-core/issues/626" rel="noopener noreferrer"&gt;core#626&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The rule above is still the rule — it is a MongoDB rule, not a Datanika one, and it will save you the same afternoon in &lt;code&gt;mongosh&lt;/code&gt;, in your application code, and in whatever else you point at that cluster. It is our &lt;em&gt;connector&lt;/em&gt; that cannot reach Atlas yet, and we would rather say so on the page than let you find out at the first run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The general version
&lt;/h2&gt;

&lt;p&gt;The lesson generalises past MongoDB: &lt;strong&gt;when a connection string has one slot doing two jobs, someone is going to configure one of them by accident.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The database in a Mongo URI is a default database &lt;em&gt;and&lt;/em&gt; an auth realm. A Postgres &lt;code&gt;search_path&lt;/code&gt; is a resolution order &lt;em&gt;and&lt;/em&gt; a write target. A &lt;code&gt;role&lt;/code&gt; is often both an identity and a permission set. In every case the failure looks like a credentials problem, because the error is raised by the auth layer — and in every case checking the credentials is the one thing that will not help.&lt;/p&gt;

&lt;p&gt;When authentication fails against credentials you are sure of, stop re-checking the password and start asking &lt;strong&gt;what scope the server resolved them in&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Datanika is an open-source ELT platform — extraction, loading, transformation and scheduling in one UI. &lt;a href="https://datanika.io/connectors/" rel="noopener noreferrer"&gt;Browse the connectors&lt;/a&gt; or &lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;self-host it&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mongodb</category>
      <category>database</category>
      <category>debugging</category>
      <category>devops</category>
    </item>
    <item>
      <title>dlt and dbt Silently Filled My Disk. Here's the Cleanup System I Had to Build.</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 31 Aug 2026 12:53:35 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/dlt-and-dbt-silently-filled-my-disk-heres-the-cleanup-system-i-had-to-build-lh7</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/dlt-and-dbt-silently-filled-my-disk-heres-the-cleanup-system-i-had-to-build-lh7</guid>
      <description>&lt;p&gt;After running pipelines for a few days in production, disk usage was climbing steadily. Turns out both &lt;a href="https://dlthub.com" rel="noopener noreferrer"&gt;dlt&lt;/a&gt; and &lt;a href="https://www.getdbt.com" rel="noopener noreferrer"&gt;dbt&lt;/a&gt; leave temporary files everywhere, and neither cleans up after itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;dlt&lt;/strong&gt; creates a pipeline directory per run with state files, staging data, and load packages. If you use the same pipeline name, old state accumulates. If you use unique names — which &lt;a href="https://datanika.io" rel="noopener noreferrer"&gt;Datanika&lt;/a&gt; does for concurrency safety — orphaned directories pile up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;dbt&lt;/strong&gt; leaves compiled SQL, manifest files, and run results in &lt;code&gt;target/&lt;/code&gt;. Every &lt;code&gt;dbt run&lt;/code&gt; adds to it. With &lt;a href="https://datanika.io/blog/dbt-per-tenant/" rel="noopener noreferrer"&gt;per-tenant dbt projects&lt;/a&gt; at &lt;code&gt;dbt_projects/tenant_{org_id}/&lt;/code&gt;, multiply that by the number of active tenants.&lt;/p&gt;

&lt;p&gt;On a single-tenant setup this is manageable. On a multi-tenant SaaS, it's a disk bomb.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Three Layers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Layer 1: Cleanup in the task itself
&lt;/h3&gt;

&lt;p&gt;Every pipeline and transformation Celery task has a &lt;code&gt;finally&lt;/code&gt; block that calls the appropriate cleanup function:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cleanup_pipeline()&lt;/code&gt; — removes dlt's working directory after the run completes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;clean_target()&lt;/code&gt; — wipes dbt's &lt;code&gt;target/&lt;/code&gt; directory before each run starts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This catches the normal case: a run starts, finishes (or fails), and its temporary files are cleaned up in the same execution context.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Hourly maintenance sweep
&lt;/h3&gt;

&lt;p&gt;A dedicated Celery Beat task runs every hour and catches what the task-level cleanup misses — orphaned files from crashed runs, zombie directories from aborted tasks, accumulated state from edge cases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sweep&lt;/th&gt;
&lt;th&gt;Default threshold&lt;/th&gt;
&lt;th&gt;What it cleans&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cleanup_orphaned_dlt_dirs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;24 hours&lt;/td&gt;
&lt;td&gt;dlt pipeline working directories older than threshold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cleanup_dbt_targets&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;48 hours&lt;/td&gt;
&lt;td&gt;dbt &lt;code&gt;target/&lt;/code&gt; directories under &lt;code&gt;tenant_*/&lt;/code&gt; older than threshold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;purge_old_runs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;90 days&lt;/td&gt;
&lt;td&gt;Soft-deletes &lt;code&gt;Run&lt;/code&gt; records older than threshold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cleanup_orphaned_archives&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Upload archive files with no matching DB record&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All thresholds are configurable via settings: &lt;code&gt;maintenance_dlt_max_age_hours&lt;/code&gt;, &lt;code&gt;maintenance_dbt_max_age_hours&lt;/code&gt;, &lt;code&gt;maintenance_run_retention_days&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: Protecting active runs
&lt;/h3&gt;

&lt;p&gt;The hourly sweep had a race condition in the first version — it could delete files from a pipeline that was still running. If a dlt run takes 2 hours and the sweep fires at hour 1.5, it would see a "stale" directory and nuke it.&lt;/p&gt;

&lt;p&gt;Fix: the sweep checks for active runs before cleaning any tenant's directories. If &lt;code&gt;Run.status == 'running'&lt;/code&gt; for any run associated with that tenant, its directories are skipped entirely. Simple, conservative, correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Config
&lt;/h2&gt;

&lt;p&gt;Four settings, all tunable per deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In .env or settings
&lt;/span&gt;&lt;span class="n"&gt;MAINTENANCE_DLT_MAX_AGE_HOURS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;      &lt;span class="c1"&gt;# dlt orphaned dirs
&lt;/span&gt;&lt;span class="n"&gt;MAINTENANCE_DBT_MAX_AGE_HOURS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt;      &lt;span class="c1"&gt;# dbt target/ dirs
&lt;/span&gt;&lt;span class="n"&gt;MAINTENANCE_RUN_RETENTION_DAYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;     &lt;span class="c1"&gt;# old run records
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Self-hosted users can adjust these based on disk size and run frequency. The defaults are conservative — 24 hours for dlt, 48 hours for dbt, 90 days for run history. On a busy system with hundreds of daily runs, you might want to tighten the dlt threshold to 12 hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;This isn't exciting work. Nobody writes blog posts about temp file cleanup.&lt;/p&gt;

&lt;p&gt;But if you're running dlt or dbt in production with any kind of frequency — especially in a &lt;a href="https://datanika.io/blog/dbt-per-tenant/" rel="noopener noreferrer"&gt;multi-tenant context&lt;/a&gt; — you need something like this or you'll wake up to a full disk at 3 AM. The disk-full failure mode is particularly nasty because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL stops accepting writes&lt;/li&gt;
&lt;li&gt;dlt can't stage extracted data&lt;/li&gt;
&lt;li&gt;dbt can't write compiled SQL&lt;/li&gt;
&lt;li&gt;Celery tasks fail silently (no disk to write logs to)&lt;/li&gt;
&lt;li&gt;Even &lt;code&gt;docker logs&lt;/code&gt; might not capture the error (log rotation relies on disk space)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three-layer cleanup — in-task, hourly sweep, race-condition protection — keeps disk usage &lt;strong&gt;predictable&lt;/strong&gt; regardless of tenant count or run frequency. On the &lt;a href="https://datanika.io/blog/saas-12-euros/" rel="noopener noreferrer"&gt;€12/mo Hetzner box&lt;/a&gt;, disk usage has been flat at ~20 GB for weeks, even with daily runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/blog/dbt-per-tenant/" rel="noopener noreferrer"&gt;dbt Per-Tenant Architecture&lt;/a&gt; — the &lt;code&gt;tenant_{org_id}/&lt;/code&gt; pattern that makes this cleanup necessary&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/blog/saas-12-euros/" rel="noopener noreferrer"&gt;My SaaS Runs on €12 a Month&lt;/a&gt; — the infrastructure that this cleanup protects&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/docs/architecture/" rel="noopener noreferrer"&gt;Architecture Overview&lt;/a&gt; — where the maintenance Celery Beat task fits in the system&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;Self-Hosting Guide&lt;/a&gt; — tuning these thresholds for your deployment&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>devops</category>
      <category>dataengineering</category>
      <category>debugging</category>
    </item>
    <item>
      <title>dbt Was Not Designed for Multi-Tenant SaaS. Here's How I Made It Work.</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 31 Aug 2026 12:48:32 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/dbt-was-not-designed-for-multi-tenant-saas-heres-how-i-made-it-work-h0c</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/dbt-was-not-designed-for-multi-tenant-saas-heres-how-i-made-it-work-h0c</guid>
      <description>&lt;p&gt;dbt wants a project directory on disk. One &lt;code&gt;dbt_project.yml&lt;/code&gt;. One &lt;code&gt;profiles.yml&lt;/code&gt;. Files in &lt;code&gt;models/&lt;/code&gt;. This works great for a single team. It doesn't work at all when you have hundreds of tenants.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://datanika.io" rel="noopener noreferrer"&gt;Datanika&lt;/a&gt;, every organization gets its own dbt project at &lt;code&gt;dbt_projects/tenant_{org_id}/&lt;/code&gt;. When a user creates a transformation in the UI, the backend writes a &lt;code&gt;.sql&lt;/code&gt; file to their tenant's directory. When they run it, we invoke dbt against that directory with the tenant's own &lt;code&gt;profiles.yml&lt;/code&gt; — generated on the fly from their destination credentials.&lt;/p&gt;

&lt;p&gt;If you want the broader multi-tenancy context (why Datanika uses a shared &lt;code&gt;public&lt;/code&gt; schema in Postgres but per-tenant directories for dbt), I wrote a separate post on &lt;a href="https://datanika.io/blog/multitenancy-mistake/" rel="noopener noreferrer"&gt;how I designed multi-tenancy wrong and then fixed it&lt;/a&gt;. This post is the dbt-specific follow-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Directory Layout
&lt;/h2&gt;

&lt;p&gt;Every tenant gets a directory that looks like a stock dbt project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dbt_projects/
├── tenant_1/
│   ├── dbt_project.yml
│   ├── profiles.yml
│   ├── models/
│   │   ├── stg_orders.sql
│   │   └── stg_orders.yml
│   ├── macros/
│   ├── tests/
│   ├── snapshots/
│   └── target/        ← ephemeral, cleaned on every run
├── tenant_2/
│   └── ...
└── tenant_N/
    └── ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ensure_project(org_id)&lt;/code&gt; creates the skeleton on demand. The profile name is &lt;code&gt;tenant_{org_id}&lt;/code&gt; — dbt's &lt;code&gt;profiles.yml&lt;/code&gt; lookup key. That's how we keep profiles isolated even though multiple tenants can share the same destination type (e.g. both tenant 1 and tenant 2 load into BigQuery, but with different service account keys).&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: target/ Fills Up the Disk
&lt;/h2&gt;

&lt;p&gt;dbt leaves &lt;code&gt;target/&lt;/code&gt; directories full of compiled SQL and run artifacts. With many tenants, disk usage grows fast. A single tenant's &lt;code&gt;target/&lt;/code&gt; is small, but 100 tenants × repeated runs = gigabytes of stale compiled SQL you'll never look at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix, part one:&lt;/strong&gt; &lt;code&gt;clean_target(org_id)&lt;/code&gt; before every pipeline and transformation run. No historical build artifacts survive past the next execution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix, part two:&lt;/strong&gt; an hourly Celery Beat task (&lt;code&gt;cleanup_dbt_targets&lt;/code&gt;) that sweeps orphaned &lt;code&gt;target/&lt;/code&gt; directories older than &lt;strong&gt;48 hours&lt;/strong&gt;. If a tenant runs nothing for 48 hours, their &lt;code&gt;target/&lt;/code&gt; is fair game for deletion — the next run will rebuild it.&lt;/p&gt;

&lt;p&gt;Together, these two loops keep disk usage predictable even as tenant count grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: schema.yml Gets Duplicate Entries
&lt;/h2&gt;

&lt;p&gt;Originally, I generated one &lt;code&gt;schema.yml&lt;/code&gt; per dbt project listing every model. It worked until users started moving models between schemas — the file kept accumulating duplicate entries as models were deleted and re-added, and dbt would throw cryptic parse errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; per-model YML files instead of one big shared file. Every &lt;code&gt;stg_orders.sql&lt;/code&gt; gets a sibling &lt;code&gt;stg_orders.yml&lt;/code&gt; with its own schema + tests declaration. Move a model? The YML moves with it. Delete a model? The YML goes too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;models/
├── stg_orders.sql
├── stg_orders.yml        ← only describes stg_orders
├── mart_revenue.sql
└── mart_revenue.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Less clever, far more robust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: The SQL Editor Needs Macro Resolution
&lt;/h2&gt;

&lt;p&gt;Datanika's &lt;a href="https://datanika.io/docs/transformations-guide/" rel="noopener noreferrer"&gt;SQL editor&lt;/a&gt; has autocomplete for &lt;code&gt;ref()&lt;/code&gt; and &lt;code&gt;source()&lt;/code&gt; macros — when you type &lt;code&gt;{{ ref(&lt;/code&gt;, it should suggest the tenant's existing models. That means parsing the tenant's dbt project to know which models and sources exist &lt;em&gt;right now&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dbt compile&lt;/code&gt; handles this correctly, but it's slow — 500ms to a few seconds depending on project size. Unacceptable for an autocomplete dropdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; cache the model/source catalog per tenant, refresh after every run. The cache lives in Redis (keyed by &lt;code&gt;org_id&lt;/code&gt;) and is invalidated on pipeline success. Autocomplete reads from cache, which is microseconds. The only lag is right after a run — and by then, the user isn't typing, they're looking at results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: Everything Else Has to Be Per-Tenant Too
&lt;/h2&gt;

&lt;p&gt;Once the directory pattern is in place, every dbt feature needs to be wired through the same tenant path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Snapshots&lt;/strong&gt; (SCD Type 2) — stored in &lt;code&gt;tenant_{org_id}/snapshots/&lt;/code&gt;, run with &lt;code&gt;dbt snapshot&lt;/code&gt; against the tenant's profile&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packages&lt;/strong&gt; (&lt;code&gt;dbt deps&lt;/code&gt;) — each tenant has its own &lt;code&gt;packages.yml&lt;/code&gt; and &lt;code&gt;dbt_packages/&lt;/code&gt; folder. Installing &lt;code&gt;dbt_utils&lt;/code&gt; is a per-tenant operation, not a global one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source freshness&lt;/strong&gt; — &lt;code&gt;tenant_{org_id}/models/sources.yml&lt;/code&gt; lists their sources with freshness thresholds; &lt;code&gt;dbt source freshness&lt;/code&gt; runs per-tenant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests&lt;/strong&gt; — generic tests (&lt;code&gt;unique&lt;/code&gt;, &lt;code&gt;not_null&lt;/code&gt;, &lt;code&gt;accepted_values&lt;/code&gt;, &lt;code&gt;relationships&lt;/code&gt;) live in the same per-model YML; singular tests go in &lt;code&gt;tenant_{org_id}/tests/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each feature required wiring through the same tenant directory pattern. None were conceptually hard. All were fiddly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Costs
&lt;/h2&gt;

&lt;p&gt;The dbt integration is easily the part of the Datanika codebase with the most "I wish dbt had a proper API for this" comments. dbt's CLI-first design assumes one project per process. Running N projects in the same Python process means managing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Working-directory changes (dbt inspects &lt;code&gt;$PWD&lt;/code&gt; aggressively)&lt;/li&gt;
&lt;li&gt;Logging output capture per invocation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;target/&lt;/code&gt; cleanup between runs so stale artifacts don't leak&lt;/li&gt;
&lt;li&gt;Profile isolation so tenant A's credentials never resolve to tenant B's warehouse&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can &lt;a href="https://datanika.io/docs/transformations-guide/" rel="noopener noreferrer"&gt;see the result&lt;/a&gt; in the UI — SQL editor, tests, snapshots, packages, freshness, all per-tenant. It works. It's not elegant under the hood, but it's contained in a single &lt;code&gt;DbtProjectService&lt;/code&gt; and the rest of the codebase doesn't have to care.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lesson
&lt;/h2&gt;

&lt;p&gt;dbt is a brilliant tool for single-tenant data teams. For multi-tenant SaaS, you have to build the tenancy layer yourself — and the right architecture is filesystem-per-tenant plus aggressive &lt;code&gt;target/&lt;/code&gt; cleanup, not something clever at the Python level.&lt;/p&gt;

&lt;p&gt;If I were starting today, I'd still use dbt-core. The alternative is rebuilding macro resolution, ref graph tracking, incremental materializations, and SCD Type 2 snapshots — all of which dbt has solved. The wrapper code is annoying, but it's once-only work.&lt;/p&gt;

&lt;p&gt;Anyone else running dbt in a multi-tenant setup? Open an &lt;a href="https://github.com/datanika-io/datanika-core/issues" rel="noopener noreferrer"&gt;issue on GitHub&lt;/a&gt; — I'd love to compare notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/docs/architecture/" rel="noopener noreferrer"&gt;Architecture overview&lt;/a&gt; — how the pieces fit together&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/docs/transformations-guide/" rel="noopener noreferrer"&gt;Transformation Guide&lt;/a&gt; — writing dbt models in the UI&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/blog/multitenancy-mistake/" rel="noopener noreferrer"&gt;Multi-tenancy mistake&lt;/a&gt; — the Postgres side of the same problem&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/blog/32-connectors-most-took-a-day/" rel="noopener noreferrer"&gt;32 connectors, most took less than a day&lt;/a&gt; — the other place where dlt and dbt's ecosystems paid off&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/datanika-io/datanika-core" rel="noopener noreferrer"&gt;Star on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dataengineering</category>
      <category>sql</category>
      <category>python</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I Designed Multi-Tenancy Wrong. Then I Fixed It Before It Mattered.</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:13:02 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/i-designed-multi-tenancy-wrong-then-i-fixed-it-before-it-mattered-2n4h</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/i-designed-multi-tenancy-wrong-then-i-fixed-it-before-it-mattered-2n4h</guid>
      <description>&lt;p&gt;My original plan for &lt;a href="https://datanika.io" rel="noopener noreferrer"&gt;Datanika&lt;/a&gt; was schema-per-tenant in PostgreSQL. Each organization gets its own schema. Clean isolation. Textbook approach.&lt;/p&gt;

&lt;p&gt;Then I started implementing services and realized: &lt;strong&gt;none of my SQLAlchemy queries set &lt;code&gt;search_path&lt;/code&gt;&lt;/strong&gt;. Every service method would need schema-switching logic. Alembic migrations would need to run against every tenant schema. Testing would become painful.&lt;/p&gt;

&lt;p&gt;So I scrapped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Did Instead
&lt;/h2&gt;

&lt;p&gt;I moved everything to the &lt;code&gt;public&lt;/code&gt; schema with an &lt;code&gt;org_id&lt;/code&gt; column on every table. Simple &lt;code&gt;WHERE&lt;/code&gt; clause filtering. One migration path. One set of queries.&lt;/p&gt;

&lt;p&gt;Every model inherits from &lt;code&gt;TenantMixin&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TenantMixin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BigInteger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organizations.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every query is scoped through a session-level filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_connections&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No schema switching, no &lt;code&gt;SET search_path&lt;/code&gt;, no per-tenant migrations. Tenant isolation is enforced by application code, not by the database catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But Schema-per-Tenant Is More Enterprise"
&lt;/h2&gt;

&lt;p&gt;This felt like a downgrade at first. Schema-per-tenant sounds more "enterprise." Hosting providers love to talk about it. AWS RDS docs reference it. It's the "right" answer in academic database textbooks.&lt;/p&gt;

&lt;p&gt;But for a SaaS where tenants share the same table structures, &lt;code&gt;org_id&lt;/code&gt; filtering wins on every dimension that actually matters:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Schema-per-tenant&lt;/th&gt;
&lt;th&gt;Shared schema + &lt;code&gt;org_id&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Migrations&lt;/td&gt;
&lt;td&gt;Run N times (one per tenant)&lt;/td&gt;
&lt;td&gt;Run once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection pooling&lt;/td&gt;
&lt;td&gt;Expensive (per-schema warmup)&lt;/td&gt;
&lt;td&gt;Cheap (shared pool)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query plans&lt;/td&gt;
&lt;td&gt;N copies (one per schema)&lt;/td&gt;
&lt;td&gt;One copy, cached&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adding a new column&lt;/td&gt;
&lt;td&gt;DDL on N schemas&lt;/td&gt;
&lt;td&gt;DDL on 1 table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing&lt;/td&gt;
&lt;td&gt;Need per-tenant test fixtures&lt;/td&gt;
&lt;td&gt;One fixture, many &lt;code&gt;org_id&lt;/code&gt; values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-tenant analytics&lt;/td&gt;
&lt;td&gt;Hard (UNION across schemas)&lt;/td&gt;
&lt;td&gt;Trivial (&lt;code&gt;GROUP BY org_id&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The only thing schema-per-tenant gives you is &lt;em&gt;physical-level&lt;/em&gt; isolation that protects against application bugs. But if your app has the kind of bug that lets one tenant query another tenant's data, the bug is in your security layer — and physical isolation is just papering over it. Better to test the security layer thoroughly. (I have &lt;a href="https://datanika.io/docs/architecture/#security" rel="noopener noreferrer"&gt;5 dedicated tenant isolation security tests&lt;/a&gt; plus 73 total security tests.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Schema Isolation Still Makes Sense
&lt;/h2&gt;

&lt;p&gt;I kept &lt;em&gt;one&lt;/em&gt; thing from the original design: &lt;strong&gt;per-tenant dbt project directories on disk&lt;/strong&gt; at &lt;code&gt;dbt_projects/tenant_{org_id}/&lt;/code&gt;. dbt needs real files on disk, and a single dbt project can't cleanly handle multiple tenants' models, sources, and configs in one directory tree. So that isolation still makes sense — different problem, different solution.&lt;/p&gt;

&lt;p&gt;The pattern I learned: pick the isolation model &lt;strong&gt;per concern&lt;/strong&gt;, not per database.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App data with identical schemas&lt;/strong&gt;: shared table + tenant column&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-tenant SQL files dbt needs to read&lt;/strong&gt;: filesystem-level isolation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-tenant secrets or keys&lt;/strong&gt;: encrypted column with rotation, not separate vaults&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test Suite Bonus
&lt;/h2&gt;

&lt;p&gt;The biggest unexpected win: my entire test suite runs on &lt;strong&gt;in-memory SQLite&lt;/strong&gt; because there's no schema-switching to worry about. 1,400+ tests run in under a minute. CI is fast. Local development is fast. New engineers (well, future me) can run tests without spinning up a PostgreSQL container.&lt;/p&gt;

&lt;p&gt;If I'd gone with schema-per-tenant, every test would need a real Postgres connection to set &lt;code&gt;search_path&lt;/code&gt;. SQLite doesn't even support schemas the way Postgres does. The test suite would have been 5–10× slower.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lesson
&lt;/h2&gt;

&lt;p&gt;Pick the isolation model that matches your &lt;strong&gt;actual query patterns&lt;/strong&gt;, not the one that looks best in an architecture diagram. For config tables with identical schemas across tenants, a shared table with a tenant column wins.&lt;/p&gt;

&lt;p&gt;Pivots early in a project are cheap. Pivots after you've shipped to customers are expensive. If you're starting a multi-tenant project right now, write a few service methods first and &lt;em&gt;see how the queries look&lt;/em&gt; before committing to a schema design.&lt;/p&gt;

&lt;p&gt;Have you hit a similar architecture pivot early in a project? Reply on the &lt;a href="https://github.com/datanika-io/datanika-core/discussions" rel="noopener noreferrer"&gt;GitHub discussions&lt;/a&gt; — I'm collecting war stories.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datanika.io/docs/architecture/" rel="noopener noreferrer"&gt;Architecture overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;Self-host with Docker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datanika.io/blog/solo-etl-platform-18-phases/" rel="noopener noreferrer"&gt;Why I built the whole thing solo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/datanika-io/datanika-core" rel="noopener noreferrer"&gt;Star on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>postgres</category>
      <category>saas</category>
      <category>python</category>
    </item>
    <item>
      <title>2,300 Passing Tests and a CSV That Loaded One Row</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:06:18 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/2300-passing-tests-and-a-csv-that-loaded-one-row-5a7m</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/2300-passing-tests-and-a-csv-that-loaded-one-row-5a7m</guid>
      <description>&lt;p&gt;Someone on the team went to take a screenshot of a first pipeline run. Twelve rows of &lt;code&gt;customers.csv&lt;/code&gt;, into DuckDB — the &lt;a href="https://datanika.io/templates/csv-to-duckdb/" rel="noopener noreferrer"&gt;zero-credentials template&lt;/a&gt; our own &lt;a href="https://datanika.io/docs/connectors/csv/" rel="noopener noreferrer"&gt;CSV guide&lt;/a&gt; calls &lt;em&gt;"the first pipeline you ever run on Datanika."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The run came back green. &lt;strong&gt;One row landed.&lt;/strong&gt; Its columns were:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;file&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;relative&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;file&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;mime&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;modification&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not one customer column. Not a name, not an email, not an ID. The connector had faithfully loaded a &lt;em&gt;description of the file&lt;/em&gt; instead of the file.&lt;/p&gt;

&lt;p&gt;At that moment the test suite was at roughly 2,300 tests, all passing, including a dedicated block of them for exactly this code path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the suite could not have failed
&lt;/h2&gt;

&lt;p&gt;Here is the shape of every test we had for the file-source builder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datanika.services.dlt_runner.filesystem&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_build_csv_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mock_fs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;mock_fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;csv_src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bucket_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://bucket/data/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;mock_fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert_called_once_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://bucket/data/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_glob&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;csv_src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the last two lines slowly. We assert that we called the library with the arguments we just passed in, and then we assert that the return value equals the string we ourselves told the mock to return.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both assertions are about our own typing.&lt;/strong&gt; Neither one can observe what the library actually does. &lt;code&gt;filesystem()&lt;/code&gt; in dlt is a &lt;em&gt;lister&lt;/em&gt; — it yields file metadata, and you are expected to pipe it through a transformer like &lt;code&gt;read_csv()&lt;/code&gt; to get contents. We never added the transformer. There were zero occurrences of &lt;code&gt;read_csv&lt;/code&gt;, &lt;code&gt;read_jsonl&lt;/code&gt; or &lt;code&gt;read_parquet&lt;/code&gt; anywhere in the package.&lt;/p&gt;

&lt;p&gt;The mock happily played the part of a working connector, because a mock will play any part you write for it. The suite was not weak here, and it was not under-maintained. &lt;strong&gt;It was structurally incapable of failing&lt;/strong&gt;, and it had been green for months on that basis.&lt;/p&gt;

&lt;p&gt;That is the part worth sitting with. A failing test tells you something. A passing test tells you something &lt;em&gt;only if it could have failed&lt;/em&gt;. We had 2,300 signals and no way to tell which of them carried information.&lt;/p&gt;

&lt;h2&gt;
  
  
  So we went looking for the others
&lt;/h2&gt;

&lt;p&gt;If one connector could be this broken behind green tests, the honest assumption is that others were too. So we stopped asking "do the tests pass" and started asking a different question for every connector: &lt;strong&gt;has this one ever been proven to move a single row?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not "is it implemented." Not "is it covered." Has a row gone in one end and come out the other, against a real database, observed in the destination.&lt;/p&gt;

&lt;p&gt;The answer for a lot of them was no. What that turned up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A glob matching zero files reported success.&lt;/strong&gt; Point a source at the wrong path, or an S3 prefix someone emptied, and you got a green run with zero rows in about four seconds. Wrong path, moved file, and correct-but-empty were indistinguishable. Test Connection couldn't help either — it returned &lt;em&gt;"Test not applicable for this type"&lt;/em&gt; unconditionally for every non-database source, so a broken path tested exactly like a working one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DuckDB loads never reached the Data Catalog.&lt;/strong&gt; The &lt;code&gt;duckdb_engine&lt;/code&gt; driver was missing from the image, and the failure was swallowed as &lt;em&gt;"Catalog sync failed (non-fatal)."&lt;/em&gt; Both of our getting-started guides tell you to verify your first run by browsing the Catalog. For DuckDB, that had never once worked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three warehouse destinations couldn't authenticate.&lt;/strong&gt; BigQuery, Databricks and Synapse stored credentials under the names &lt;em&gt;our&lt;/em&gt; form used and handed them to dlt, which wanted different names — BigQuery stores &lt;code&gt;project&lt;/code&gt; and &lt;code&gt;keyfile_json&lt;/code&gt; where dlt expects &lt;code&gt;project_id&lt;/code&gt;, &lt;code&gt;private_key&lt;/code&gt; and &lt;code&gt;client_email&lt;/code&gt;, and nothing parsed the keyfile at all. The translation layer covered SQL databases only. Nobody had noticed, because nobody had completed a run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://datanika.io/connectors/kafka/" rel="noopener noreferrer"&gt;Kafka&lt;/a&gt; subscribed to a topic that didn't exist.&lt;/strong&gt; The connection form stores topics as a comma-separated string. The builder did &lt;code&gt;topics if isinstance(topics, list) else [topics]&lt;/code&gt; — so &lt;code&gt;orders,events&lt;/code&gt; became a single topic named, literally, &lt;code&gt;orders,events&lt;/code&gt;. It subscribed successfully. It consumed nothing, forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://datanika.io/connectors/mongodb/" rel="noopener noreferrer"&gt;MongoDB&lt;/a&gt; looked for your user in the wrong database.&lt;/strong&gt; The database in a Mongo URI doubles as the auth database, so omitting &lt;code&gt;authSource&lt;/code&gt; means "the user lives inside the database you're reading" — which is not where anyone puts it. Atlas, every managed provider, and every Docker setup using &lt;code&gt;MONGO_INITDB_ROOT_USERNAME&lt;/code&gt; create users in &lt;code&gt;admin&lt;/code&gt;. First connection attempt failed for basically everyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And failure notifications had never fired.&lt;/strong&gt; This one is the sharpest, because we had &lt;a href="https://datanika.io/blog/slack-alerts-pipeline-failures/" rel="noopener noreferrer"&gt;written a tutorial about it&lt;/a&gt;. The Slack, Telegram, email and webhook channels were all real, the &lt;code&gt;Run Failed&lt;/code&gt; event was real, and the handler that formats the message was real. Nothing ever &lt;em&gt;emitted&lt;/em&gt; the failed-run event. The branch was unreachable code, and had been since it shipped. It works now — but for a while the honest description of that feature was "a correctly implemented notification for an event that is never announced."&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule we apply now
&lt;/h2&gt;

&lt;p&gt;Every one of those is fixed and live. But the fixes are less interesting than the rule that came out of it, which is now how we review connector work:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A connector is not done when its tests pass. It is done when a row has been observed in the destination.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Concretely, three things changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probes run against the real thing.&lt;/strong&gt; Postgres, MySQL, MongoDB, Kafka and DuckDB now have tests that spin up a real container, load real rows, and query the destination to count them. Not the run status — the destination. A green run means the load finished, which is a different claim from "your data is there."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every fix ships with a probe we watched fail.&lt;/strong&gt; Before a fix lands, its test is run against the &lt;em&gt;broken&lt;/em&gt; code and has to go red. This sounds obvious. It is also exactly the step that, had anyone taken it on the file connector, would have caught all of this months earlier — because the moment you feed that test a realistic listing-shaped payload, the assertion &lt;code&gt;result == "csv_src"&lt;/code&gt; is revealed as meaningless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Known-broken connectors are pinned red, not skipped.&lt;/strong&gt; Where something can't be proven yet, it carries a strict expected-failure marker rather than a skip. A skip is silent forever. A strict marker fails the build the day the behaviour starts working, which forces someone to notice and remove it. &lt;strong&gt;A test that starts passing should be an event, not a non-event.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we still can't claim
&lt;/h2&gt;

&lt;p&gt;The audit is not finished, and it would be a bad look to write this post and then round up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Snowflake and Synapse are verified at the configuration layer only.&lt;/strong&gt; We check that the credentials we hand to dlt are the ones dlt declares it wants. Nothing has connected. No ODBC driver exists in our test environment, and a Snowflake trial signup was refused by their risk engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Analytics and Facebook Ads are implemented, not proven.&lt;/strong&gt; Both were built against the documented API shapes with no credentials anywhere, including in CI. Their first real run will be the actual test, and we would rather say that than let you find out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Ads is row-proven against a local HTTP server&lt;/strong&gt;, not against Google. The parsing, flattening and auth headers are verified; the round trip to Google's own API is not.&lt;/li&gt;
&lt;li&gt;The tracking issue for the whole audit is &lt;strong&gt;still open&lt;/strong&gt;. We are working through the remaining builders in the same way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We would rather publish that list than a rounder number. If you are evaluating a data platform, the useful question is not how many connectors it claims — it is which of them anyone has watched move a row, and whether the vendor will tell you the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a post at all
&lt;/h2&gt;

&lt;p&gt;There is an argument for not writing this one. It is a catalogue of our own bugs, some of them embarrassing, on the exact path a new user takes first.&lt;/p&gt;

&lt;p&gt;We are writing it because the alternative is worse. If you self-host Datanika you can read every line of this in the commit history anyway — the issues are public, the fixes are public, and the test that couldn't fail is right there in the diff. An open-source project that only publishes its wins is asking you to trust a filtered feed of a repository you can just go read.&lt;/p&gt;

&lt;p&gt;And the general lesson isn't ours alone. If you have a connector, an SDK wrapper, or any integration tested entirely by patching the library at the boundary, the odds are decent that your suite is asserting your own arguments back at you. The cheap check is to break the code on purpose and confirm something turns red. If nothing does, you don't have a test — you have a very long, very green description of what you intended.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Datanika is an open-source platform that runs dlt extract-and-load and dbt-core transformations behind one UI, with &lt;a href="https://datanika.io/connectors/" rel="noopener noreferrer"&gt;36 connectors&lt;/a&gt;, scheduling and run history. &lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;Self-host it&lt;/a&gt; or &lt;a href="https://app.datanika.io/register" rel="noopener noreferrer"&gt;start free&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>python</category>
      <category>programming</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Wrote a Full SaaS UI Without a Single Line of JavaScript</title>
      <dc:creator>Evgenii Timofeev</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:05:32 +0000</pubDate>
      <link>https://dev.to/eu_ti_f127c5b5d7535b7174f/i-wrote-a-full-saas-ui-without-a-single-line-of-javascript-10o8</link>
      <guid>https://dev.to/eu_ti_f127c5b5d7535b7174f/i-wrote-a-full-saas-ui-without-a-single-line-of-javascript-10o8</guid>
      <description>&lt;p&gt;No React. No TypeScript. No separate frontend repo. Just Python.&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://datanika.io" rel="noopener noreferrer"&gt;Datanika&lt;/a&gt; — a data pipeline platform — and chose &lt;a href="https://reflex.dev" rel="noopener noreferrer"&gt;Reflex&lt;/a&gt; for the entire UI. Reflex compiles Python to React under the hood. You write state classes and component functions in Python, and it handles the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Reflex
&lt;/h2&gt;

&lt;p&gt;I'm a solo developer. Maintaining two codebases (Python backend + JavaScript frontend) would have killed the project before it shipped. Every feature would need API contracts, type duplication, a separate dependency tree, and a separate CI pipeline. For a single person shipping &lt;a href="https://datanika.io/blog/solo-etl-platform-18-phases/" rel="noopener noreferrer"&gt;18 phases in a month&lt;/a&gt;, that's not scope control — that's guaranteed failure.&lt;/p&gt;

&lt;p&gt;With Reflex, my pipeline service, my UI state, and my page components all share the same language, the same types, the same imports. Need to show a list of connections on the frontend? The state class calls the service class directly. No fetch, no JSON parsing, no zod schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConnectionState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseState&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;connections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;AsyncSessionLocal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ConnectionService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_org_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service returns Python objects, the state holds them, the page renders them. One mental model, one type system, one debugger.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's Not Perfect
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend Reflex is flawless. It isn't. A few things bit me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reflex 0.8.x uses Starlette internally, not FastAPI.&lt;/strong&gt; Custom API routes can't use FastAPI decorators — you have to build &lt;code&gt;starlette.routing.Route&lt;/code&gt; objects and append them to &lt;code&gt;app._api.routes&lt;/code&gt;. This bit me when wiring up OAuth callbacks for Google and GitHub sign-in, and again when adding the Paddle webhook endpoint. It's not hard, but it's not documented anywhere prominently either. I wrote it down in my &lt;a href="https://datanika.io/docs/architecture/" rel="noopener noreferrer"&gt;architecture doc&lt;/a&gt; so I'd never have to re-discover it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The WebSocket event system has quirks.&lt;/strong&gt; Reflex uses WebSockets to sync state between the Python backend and the React frontend. Most of the time it just works. Occasionally you hit an edge case where a state update doesn't propagate, and the fix is adding a &lt;code&gt;yield&lt;/code&gt; in an async handler that doesn't obviously need one. I learned to treat "the button didn't update" as "missing yield" before any other hypothesis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrapping React components is possible but the docs are thin.&lt;/strong&gt; When you need something Reflex doesn't ship natively — a cron expression picker, a DAG/graph visualizer, a Monaco SQL editor — you wrap a React component in Python. It works. The wrapper pattern is clean. But the docs are thin, so I learned it by reading Reflex's own source code for the components they do ship.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hot reload is sometimes confused.&lt;/strong&gt; Reflex's dev server watches Python files and recompiles. When you touch &lt;code&gt;content.config.ts&lt;/code&gt; or similar, the recompile sometimes picks up stale state. &lt;code&gt;Ctrl+C&lt;/code&gt;, restart, done. Rare but annoying.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Got in Return
&lt;/h2&gt;

&lt;p&gt;The tradeoff was worth it. I built the following, all in Python, in one repo, by myself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Login, signup, social OAuth (Google + GitHub)&lt;/li&gt;
&lt;li&gt;SAML + OIDC SSO for Enterprise&lt;/li&gt;
&lt;li&gt;Pipeline builder with connection picker and load modes&lt;/li&gt;
&lt;li&gt;Full-screen SQL editor with autocomplete and dbt macro resolution&lt;/li&gt;
&lt;li&gt;DAG visualizer for pipeline dependencies&lt;/li&gt;
&lt;li&gt;Drag-and-drop CSV / JSON / Parquet file uploads&lt;/li&gt;
&lt;li&gt;Multi-org with RBAC (owner/admin/editor/viewer)&lt;/li&gt;
&lt;li&gt;Billing settings page with Paddle overlay checkout&lt;/li&gt;
&lt;li&gt;Notification channels config (Slack, Telegram, email, webhook)&lt;/li&gt;
&lt;li&gt;9-language i18n with runtime switching&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/docs/architecture/" rel="noopener noreferrer"&gt;Data catalog&lt;/a&gt; browsing all tables and models&lt;/li&gt;
&lt;li&gt;API keys management page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those would have needed a separate React component, a state-management integration, a TypeScript type, and a test setup if I'd used a split-stack architecture. Instead they're Python functions calling Python services calling Python models.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Layered Architecture Payoff
&lt;/h2&gt;

&lt;p&gt;Because everything is Python, the layered architecture I set up in phase 1 extends all the way to the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;models/      → ORM, no logic
services/    → business logic, all state changes
tasks/       → Celery wrappers around services
ui/state/    → Reflex state classes, call services and tasks
ui/pages/    → functions returning rx.Component, no logic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer imports the one below it. Reflex pages import state classes, which import services, which import models. No layer skips down. No layer imports up. When I add a new feature, I add one thing to each layer, in order.&lt;/p&gt;

&lt;p&gt;This is the same layering I described in the &lt;a href="https://datanika.io/blog/multitenancy-mistake/" rel="noopener noreferrer"&gt;multi-tenancy mistake post&lt;/a&gt; — and it's the same one that made the &lt;a href="https://datanika.io/blog/dbt-per-tenant/" rel="noopener noreferrer"&gt;dbt-per-tenant complexity&lt;/a&gt; containable. Put the gnarly stuff in a service, expose it through a clean interface, and the layer above it doesn't have to care.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Reflex Fits — and When It Doesn't
&lt;/h2&gt;

&lt;p&gt;Reflex isn't the right answer for every app. I wouldn't use it for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public marketing sites&lt;/strong&gt; — you want Astro, Next.js, or similar. This blog is built with Astro, not Reflex, precisely because it's static content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumer apps with heavy client-side state&lt;/strong&gt; — games, editors, real-time collab tools. Reflex can do it, but you're fighting the framework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apps where the frontend team is separate from the backend team&lt;/strong&gt; — the whole point of a unified stack is that the same person owns both. If you have a dedicated React team, give them React.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But for &lt;strong&gt;internal tools, B2B SaaS, admin panels, data platforms&lt;/strong&gt;, Reflex is seriously underrated. These apps have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forms and tables (Reflex strength)&lt;/li&gt;
&lt;li&gt;CRUD workflows (Reflex strength)&lt;/li&gt;
&lt;li&gt;Server-driven state (Reflex strength)&lt;/li&gt;
&lt;li&gt;Small frontend teams or no frontend team at all (Reflex strength)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're a backend developer building a SaaS and dreading the frontend, Reflex is worth a serious look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;Everything I described above is open source under AGPL-3.0. You can clone the Datanika repo, run &lt;code&gt;docker compose up -d&lt;/code&gt;, and see Reflex in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datanika.io/docs/self-hosting/" rel="noopener noreferrer"&gt;Self-host with Docker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/docs/architecture/" rel="noopener noreferrer"&gt;Architecture overview&lt;/a&gt; — including the "Reflex 0.8.x uses Starlette" note buried in there&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/datanika-io/datanika-core" rel="noopener noreferrer"&gt;Star on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://app.datanika.io" rel="noopener noreferrer"&gt;Try the cloud version free&lt;/a&gt; — same codebase&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Curious what others think about Python-only full-stack. Has anyone else tried this for production SaaS? Open an &lt;a href="https://github.com/datanika-io/datanika-core/issues" rel="noopener noreferrer"&gt;issue or discussion on GitHub&lt;/a&gt; — I'd love to compare notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/blog/solo-etl-platform-18-phases/" rel="noopener noreferrer"&gt;I Built an ETL Platform Solo. 18 Phases, 1,400+ Tests, One Month.&lt;/a&gt; — how the scope control worked&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/blog/multitenancy-mistake/" rel="noopener noreferrer"&gt;I Designed Multi-Tenancy Wrong&lt;/a&gt; — where layered architecture started paying off&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datanika.io/blog/dbt-per-tenant/" rel="noopener noreferrer"&gt;dbt Was Not Designed for Multi-Tenant SaaS&lt;/a&gt; — same layering applied to dbt integration&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
