Fundamentos / El modelo multi-tenant
Multi Tenant

El modelo multi-tenant

Cómo White Label soporta múltiples marcas sobre la misma base de código: un binario por tenant, inyección de dependencias con FX y una jerarquía clara para modelar las diferencias entre marcas.

El problema

White Label corre para múltiples marcas: Jumbo, Disco, Prezunic, Santa Isabel, entre otras. Cada una tiene sus particularidades: distintos proveedores, distintos flujos de autenticación, distintas reglas de validación, distintos mercados.

La pregunta de diseño es cómo modelar esas diferencias sin que el código se convierta en una acumulación de condicionales por marca, y sin duplicar lógica entre servicios que hacen esencialmente lo mismo.

La respuesta está en cómo se construye el sistema al arrancar, no en cómo se comporta mientras corre.

Features, no tenants

Las reglas y flags se definen en términos de la feature, no del tenant que la usa. No existe un SignInPrezunic ni un flag IsBrasil. Existe UseOAuth, que hoy activa Prezunic y mañana puede activar cualquier otra marca que adopte el mismo mecanismo sin solamente activando ese flag para el tenant que lo solicite.

Este principio requiere un esfuerzo consciente al diseñar. La tentación natural es crear una regla con el nombre del tenant y justificarla como “esto existe porque así lo hace fulano”. Eso es exactamente lo que hay que evitar. Antes de definir una regla hay que identificar la feature subyacente, nombrarla en esos términos e inyectarla como comportamiento configurable. El tenant que la usa hoy es un detalle de configuración, no parte del diseño.

Un deployment, un tenant

Cada instancia del sistema corre configurada para una sola marca. El tenant se especifica al arrancar el servicio como argumento:

main server --tenant=br-prezunic
main server --tenant=co-jumbo
main server --tenant=ar-disco

Ese valor se inyecta como el primer nodo del grafo de FX. A partir de ahí, todos los módulos que necesiten comportarse distinto según la marca reciben ese valor como dependencia y resuelven su configuración en tiempo de construcción, antes de que el servidor empiece a recibir tráfico.

No hay detección de tenant en runtime. No hay un mapa de tenants que se consulta en cada request. Las decisiones están tomadas desde el momento en que el proceso arranca.

tenant, err := cmd.Flags().GetString("tenant")
if err != nil {
	log.Println("🚨 Error reading tenant argument:", err)
	os.Exit(1)
}

app := fx.New(
	// Tenant
	fx.Provide(func() (config.TenantValue, error) {
		return config.NewTenant(tenant)
	}),
	// Modules
	common.Module,
	platform.Module,
	health.Module,
	auth.Module,
)

app.Run()

Los tres niveles de variación

Las diferencias entre tenants se modelan con tres mecanismos, en orden de complejidad creciente. El criterio para elegir cuál usar es simple: empezar por el más simple y subir de nivel solo cuando la complejidad lo justifica.

Nivel 1 — Flag

Un flag es un booleano que activa o desactiva una feature o comportamiento. Cuando el flag está activo, se ejecuta el comportamiento asociado. Cuando no lo está, se ejecuta el comportamiento por defecto, o simplemente no se hace nada.

UseOAuth es el ejemplo más claro: todos los tenants necesitan autenticación, pero el flujo varía. El comportamiento por defecto es autenticación via VTEX. Prezunic activa UseOAuth y el flujo cambia por completo a OAuth. El mismo caso de uso, dos caminos de ejecución, un solo flag que decide cuál tomar.

Tip

Revisar el micro de core auth para cómo cambia el flujo de auth según el tenant

Nivel 2 — Function type

Una function type modela una regla que siempre aplica pero que cada tenant implementa de forma distinta. Se define como un tipo de función con una firma clara (entrada y salida esperadas), se proveen implementaciones comunes reutilizables y las específicas por tenant, y cada tenant recibe la suya al construir el core.

Ejemplo function type definición
type UsernameSanitizer func(string) string
type UsernameValidator func(string) error

// Implementaciones comunes
func EmptySanitizer(username string) string {
    return username
}

func EmailValidator(email string) error {
    pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
    if !regexp.MustCompile(pattern).MatchString(email) {
        return errors.New("invalid email")
    }
    return nil
}
Ejemplo function type inyección
sanitizer := rule.EmptySanitizer
validator := rule.EmailValidator

switch tenant {
case config.BR_PREZUNIC:
    sanitizer = prezunic.CpfSanitizer
    validator = prezunic.CpfValidator
}

La mayoría de tenants usa email como identificador y recibe EmailValidator. Prezunic usa CPF y recibe CpfValidator. El caso de uso que invoca el validador no sabe cuál está usando, y no necesita saberlo.

Las implementaciones específicas de un tenant viven en una carpeta con su nombre dentro del core, por ejemplo core/prezunic/. Eso las mantiene aisladas y fáciles de localizar.

Ejemplo core configurando reglas

func NewAuthLogic(
	tenant config.TenantValue,
	cfg *config.AppConfig,
	// Más dependencias
	... 
) *AuthLogic {
	// Reglas comunes
	withUsername := vtex.WithEmail
	useOAuth := false
	sanitizer := rule.EmptySanitizer
	validator := rule.EmailValidator
	var idTokenDecoder rule.IdTokenDecoder = nil
	var userInfoDecoder rule.UserInfoDecoder = nil
	var usernameExtractor rule.UsernameExtractor = rule.EmailExtractor
	
	// Modificamos comportamiento por defecto según el tenant
	switch tenant {
	case config.BR_PREZUNIC:
		withUsername = vtex.WithDocument
		useOAuth = true
		sanitizer = prezunic.CpfSanitizer
		validator = prezunic.CpfValidator
		idTokenDecoder = prezunic.IdTokenDecoder
		userInfoDecoder = prezunic.UserInfoDecoder
		usernameExtractor = prezunic.UsernameExtractor
	}

	return &AuthLogic{
		// Flags
		UseOAuth: useOAuth,
		// Reglas function type
		usernameSanitizer: sanitizer,
		usernameValidator: validator,
		idTokenDecoder:    idTokenDecoder,
		userInfoDecoder:   userInfoDecoder,
		usernameExtractor: usernameExtractor,
		// Filter for vtex search (esto es un function type)
		withUsername: withUsername,
		// Configs
		defaultPostalCode: cfg.Vtex.DefaultPostalCode,
		// Más dependencias
		... 
	}
}
Ejemplo caso de uso usango reglas
func (c *AuthLogic) SignIn(
	ctx context.Context,
	username string,
	password string,
) (res *auth.SignInResponse, err error) {
	username = c.usernameSanitizer(username)
	if err := c.usernameValidator(username); err != nil {
		return nil, err
	}

	if c.UseOAuth {
		res, err = c.SignInOAuth(ctx, username, password)
	} else {
		res, err = c.SignInVtex(ctx, username, password)
	}

	if err != nil {
		c.publishSignInEvent(ctx, username)
	}

	return res, err
}

Este caso de uso integra los dos primeros niveles en acción. usernameSanitizer y usernameValidator son function types: siempre se invocan, pero cada tenant tiene su implementación. Prezunic sanitiza y valida un CPF, el resto sanitiza y valida un email. El caso de uso no distingue entre uno y otro.

UseOAuth es un flag: bifurca el flujo completo hacia OAuth o hacia VTEX según el tenant. Dos caminos de ejecución distintos, resueltos en una sola línea.

Finalmente, publishSignInEvent se ejecuta sin condicionales. Publicar el evento de sign in es un comportamiento transversal a todos los tenants: no depende de reglas ni de flags, aplica siempre.

Nivel 3 — Interface

Una interface se usa cuando la regla tiene dependencias de plataforma distintas según el tenant, haciendo que una simple función no alcance. Es el nivel más complejo y debe usarse solo cuando los dos anteriores no son suficientes.

SellerValidator es el ejemplo canónico. Validar si un seller puede atender una dirección es una regla que todos los tenants necesitan, pero la forma de resolverla varía estructuralmente: algunos tenants la resuelven consultando regiones en VTEX, otros necesitan además un CMS para obtener las tiendas del país antes de correr una simulación de orden. No es solo una función diferente: son dependencias diferentes.

Interface definition
type SellerValidator interface {
    ValidateSellers(
        ctx context.Context,
        req *v1.ValidateSellersRequest,
    ) (*v1.ValidateSellersResponse, error)
}

Note

v1.ValidateSellersRequest y v1.ValidateSellersResponse son structs generados por Protobuf. No son DTOs ni entidades de dominio propias: son el modelo. Las reglas de negocio, los casos de uso y los servicios todos hablan el mismo lenguaje porque todos usan los mismos tipos generados a partir del .proto. Esto es el principio de Protobuf como modelo único en acción.

Hay dos implementaciones concretas. SellerValidatorRegion resuelve la validación consultando regiones directamente en VTEX y solo necesita el cliente de VTEX. SellerValidatorSimulation necesita además un cliente de CMS para obtener las tiendas del país antes de correr una simulación de orden en VTEX.

La selección de cuál implementación usar se resuelve en el módulo FX del SellerValidator, no en el core. El módulo recibe el tenant y las dependencias disponibles, construye la implementación correcta y la provee como SellerValidator al grafo.

var Module = fx.Module(
    "sellervalidator",
    fx.Provide(
        func(
            tenant config.TenantValue,
            cms *cms.CmsClient,
            vtex *vtex.VtexClient,
        ) SellerValidator {
            switch tenant {
            case config.AR_JUMBO, config.AR_DISCO:
                return NewSellerValidatorSimulation(cms, vtex)
            case config.CO_JUMBO, config.BR_PREZUNIC:
                return NewSellerValidatorRegion(vtex)
            default:
                return nil
            }
        },
    ),
)

El core recibe un SellerValidator y llama a ValidateSellers sin saber qué implementación está usando. La complejidad de seleccionar y construir la implementación correcta queda encapsulada en el módulo FX, que es el lugar natural para ese tipo de decisiones de composición.

Este es el patrón a seguir cada vez que una regla requiere dependencias propias: definir una interface mínima con el comportamiento necesario, crear una implementación por variante, y resolver la selección en el módulo FX correspondiente.

Principios

Un binario, un tenant. Cada instancia del sistema corre configurada para una sola marca. No hay detección de tenant en runtime ni mapas de configuración que se consultan en cada request. Las decisiones están tomadas desde el momento en que el proceso arranca.

Features, no tenants. Las reglas y flags se nombran en términos de la feature que modelan, no del tenant que las usa. UseOAuth, no IsPrezunic. Una regla bien nombrada puede ser adoptada por cualquier tenant futuro sin tocar su definición.

La variación se modela explícitamente. Las diferencias entre tenants no se esconden detrás de condicionales dispersos. Se declaran en el constructor del core o en el módulo FX que corresponda, en un solo lugar, antes de que el servidor empiece a correr.

Complejidad mínima necesaria. El orden de preferencia es flag → function type → interface. Cada nivel agrega expresividad pero también complejidad. Se sube de nivel solo cuando el nivel anterior no alcanza para modelar la diferencia.


Tip

Para entender cómo los microservicios y dominios se comunican entre sí a través de eventos, continuá con Comunicación orientada a eventos