
Every request to the ERP system must first answer two things: who is calling and which company he is in. If the framework is not managed uniformly, these two problems will grow into the beginning of each piece of business logic and become template code copied everywhere.
What the framework does is focus the answer on an object:SessionInfo. It seems to only remember “who logged in”, but in fact, database routing, customization, language, time zone, encryption, and permissions all have values on it.
Description of this article:
- How Business Object obtains the running context
- Session’s two-stage life cycle
- Session persistence and reconstruction
CultureandTimeZoneDesign trade-offs for default values
1. Running context of Business Object
The problem you will encounter when writing the first BO method: it needs to know “who is now” first, and there are three ways to obtain it.
| practice | question |
|---|---|
| Static “current user” | Servers that handle multiple requests at the same time are bound to contaminate each other. |
| Multiple parameters per method signature | At every level, things that are no longer needed need to be resold. No one will tell you if they are passed on. |
| Handed during construction | The framework adopts this |
The constructor of BO has four fixed parameters and is declared in BusinessObject.cs:
protected BusinessObject(IBeeContext ctx, Guid accessToken, string progId, bool isLocalCall = true)
ctx: A whole package of parsed services (see below)accessToken: The session identifier of this callprogId: Program identification code of the calling objectisLocalCall: Whether it is called by the same process
All BOs share the same signature so that the factory does not have to know which family to build (the registration form itself is a topic of Day 15).
IBeeContext What is installed?
The contents of this package are fully stated in IBeeContext superior:
public interface IBeeContext
{
IDefineAccess DefineAccess { get; } // 定义访问
ISessionInfoService SessionInfoService { get; } // Session 访问
ILanguageService LanguageService { get; } // 语言文字
IBusinessObjectFactory BoFactory { get; } // BO 之间互相调用
IServiceProvider Services { get; } // 逃生门
}
The first four are named because they are used by almost every BO method. The fifth is the escape door, which is only used by a few methods, such as the auxiliary services required for logging in. It is deliberately written in the same fixed way, so who uses it and where it is used can be found out with a global search. It is better to open a marked escape door than to have someone dig an unmarked hole.
Things purposely not included in this pack:
- No
DbConnection - No
DbTransaction - No connection string
When BO wants to access data, it will always go to the Repository. The database classification declared by the Repository itself, plus the company parsed by the session, determines which database to connect to (Day 11 has been expanded).
There is another question, the answer is also not in this package: whether completing this section is considered a success. The transaction is not opened by BO. When saving, the Repository will open a transaction to cover the entire document. Master first, then detail, and then commit only if all are successful. Which side of the transaction the few short paragraphs rewritten by BO run on is the title of tomorrow’s entire article.
The most inconspicuous of the four parameters is accessTokenit is just a Guid. And everything BO needs to know is ultimately obtained in exchange for it.
2. Session’s two-stage life cycle
Practical issues when deploying to multiple companies: Put companyId go hand in hand Login Three structural barriers will be encountered.
- The order is reversed: Which companies a user can enter can only be found out after identity verification.
- Switching costs are high: switching companies means logging out and logging in again, tokens are reissued, encryption keys are rebuilt, and all unsaved data on the interface disappears.
- There is no way to express the intermediate state: “Logged in, not yet in the company” there is no field to put
The framework therefore cuts the session into two parts, with four symmetrical methods:
Login(account, password) ←→ Logout()
EnterCompany(companyId) ←→ LeaveCompany()
The two pairs are nested, with the outer layer managing the identity and the inner layer managing the company:
| method | after finishing |
|---|---|
Login |
Have an identity. Can call methods that are not tied to a company,EnterCompany Just one of them |
EnterCompany |
There is a corporate context.company Which database should be connected to which category? Only then can we figure it out. |
LeaveCompany |
The company context is cleared, but the session is still alive. |
Logout |
When the session is destroyed, the company context will be cleared internally first. |
Two things you can’t tell from the table:
- You don’t have to switch companies first
LeaveCompany,EnterCompanyDirectly overwrite the old value (two steps will produce a non-atomic intermediate state) - The company does not exist, company is disabled, and no access rights are combined into one error to avoid being used to enumerate which companies are in the system.
Login failure handling
Login It is anonymous and can be called by anyone, so it also has a counter attached to it.ILoginAttemptTracker If the same account fails several times in a row, it will be reset to zero if the login is successful; the default implementation attached to the framework will lock that account for a period of time after a certain number of consecutive failures, and the host can be completely replaced.
How long to lock, the account or the source of the lock, and whether to notify or not are determined by each company’s information security policy: some must comply with the company’s existing account regulations, some must receive notification channels, and some may not lock at all and only record. And “consecutive failures will be counted” is the same for every company. The judgment method on Day 2 looks like this in terms of certification.
The other half is that the framework now opens an replaceable implementation, not a setting. ERP information security management will most likely end up at the setting level, where each company has a set of thresholds; to get there, there must first be enough deployments to raise the same set of parameters.
SessionInfo What is installed?
The two-phase is not just a process convention; SessionInfo This type of shape: the public attributes are divided into two groups according to “which segment is filled in”.
Login Fill in (seven)
| property | content | consumer |
|---|---|---|
AccessToken |
session identifier (Guid) |
Every authentication |
ExpiredAt |
Expiration time (UTC) | Every authentication |
UserId |
account | Audit (Day 25) |
UserName |
display name | Audit (Day 25) |
Culture |
BCP-47 language code | Multilingual (Day 20) |
TimeZone |
IANA time zone code | Time zone conversion (Day 28) |
ApiEncryptionKey |
Symmetric key for this connection | Payload encryption (Day 23) |
EnterCompany Fill in (six)
| property | content | consumer |
|---|---|---|
CompanyId |
current company | Database Routing (Day 11) |
CustomizeId |
The customized code applied by the company | Customization (Day 19) |
Roles |
User’s role in the company | Permissions (Day 24) |
UserRowId |
st_user.sys_rowid |
Permissions (Day 24) |
EmployeeRowId |
st_employee.sys_rowid |
Permissions (Day 24) |
DeptRowId |
st_employee.dept_rowid |
Permissions (Day 24) |
CompanyId is the only one that can be null with clear semantics: logged in, but not yet in the company. Day 11 mentioned that Repository parses the route as soon as it is constructed, and this state is one of the situations that cannot be solved and will be blocked on the spot.
The company code name will also be changed to a copy CompanyInfothis copy is in the cache, the source is a query, and it will be read only after it is used for the first time. In addition to answering the company database address, it also carries company-level rewriting: number of digits, company local currency, minimum cash payment unit, and list of available currencies. What you have prepared when entering the company is not just an address, but numerical semantics itself is the topic of Day 26.
Six attributes come in and out at the same time
These six values are cleared at once by the same private method, which is the same as the following fragments in this article. SystemBusinessObject.Session.cs:
private static void ClearCompanyContext(SessionInfo sessionInfo)
{
sessionInfo.CompanyId = null;
sessionInfo.CustomizeId = string.Empty;
sessionInfo.Roles = [];
sessionInfo.UserRowId = Guid.Empty;
sessionInfo.EmployeeRowId = Guid.Empty;
sessionInfo.DeptRowId = Guid.Empty;
}
LeaveCompany and Logout This is the one who left.
3. Session persistence and reconstruction
If the server restarts or the load balancer sends the next request to another machine, does the token in hand still count?
- Only memory: does not count, everyone will be kicked offline every time a version is released
- Write the entire copy into the database: the permission to log in to the current snapshot will continue to take effect after the user is transferred out of the company
Keep both copies of the frame, but write in st_session The only ones are the session’s own identity and expiration time, plus the value that cannot be recovered during reconstruction. This part is called seed, and its type is SessionUser:
new SessionUser
{
AccessToken = sessionInfo.AccessToken,
UserID = sessionInfo.UserId,
UserName = sessionInfo.UserName,
EndTime = sessionInfo.ExpiredAt,
CompanyId = sessionInfo.CompanyId,
}
seed only holds five values, and the rest can be regenerated:
| value | Reconstruction method |
|---|---|
Culture / TimeZone |
reread st_user |
ApiEncryptionKey |
Re-derived from token |
CustomizeId / Roles / Three RowIds |
rerun EnterCompany analysis |
Only “which company the user chose” is not remembered elsewhere in the database, so it must be saved.
Rebuilding on a cache miss has three properties:
- Rerun the derivation, not restore the snapshot. Company permissions that are revoked after logging in will not survive, and the session cannot be restored directly.
EnterCompanySharing the same program as reconstruction (SessionCompanyBinder). Both paths must fall into the same state, otherwise permissions will depend on which machine the request goes to.- The seed is written first and cached later. The two intermediate states are not equivalent: if there is seed but no cache, it can be rebuilt on the next request; if there is cache but no seed, the token will die next time it is restarted, and it does not exist on other machines at all, and the client cannot detect it.
Another way to issue tokens
CreateSession Take an account and directly send a token without verifying the credentials at all. Login The only difference is this one; the whole section of deriving the key, writing the seed, and placing the cache later share the same method.
It is used for tasks such as scheduling, importing, and reconciliation that no one is sitting in front of to enter passwords, but still require an identity to run. Without it, those operations are left with two options: put a set of service account passwords in the configuration file, or simply open the connection yourself, bypassing the entire set of permissions.
The cost is that this method itself is a master key, so it is declared in the access control to only accept calls from the same process: HTTP requests are blocked before entering the method, and only programs running in the same process as the backend can call it. Section 1: The constructor isLocalCall The same value is received, and the calling path that does not go through that verification is blocked by it.
Four,Culture and TimeZone The default value of
The default value for both properties is the empty string, and that’s intentional.
- The language service encountered a null value→return to the system default language
FrameworkClock/DateTimeZoneConverter/PayloadZoneConverterWhen encountering an empty time zone → always treat it as UTC
The semantics of an empty string is “take the default path”, not “not filled in yet”.
What actually fills in the value is the login section (repo Is the Repository of the user data table):
var locale = repo.GetLocale(sessionInfo.UserId);
var backend = DefineAccess.GetSystemSettings().BackendConfiguration;
sessionInfo.TimeZone = StringUtilities.IsNotEmpty(locale.TimeZone)
? locale.TimeZone : backend.DefaultTimeZone;
sessionInfo.Culture = StringUtilities.IsNotEmpty(locale.Culture)
? locale.Culture : backend.DefaultLanguage;
These two values go back three levels:st_user That line → BackendConfiguration →Each consumer has its own default.
BackendConfiguration The default values for these two settings are zh-TW and Asia/Taipeiso the deployment without adjusting anything will get them. This decision is placed in the settings, not in the source code: fill in the blank string to make the entire deployment follow each consumer’s own default. Unlogged calls have no session and always go to the last level.
Back to Northwind
There is only one company in the case. After logging in, you still go through the step of entering the company.
- Both sections have zero application code: log in and use the framework’s own
st_user(Hash comparison, display name peer reading), enter the company and frame your ownEnterCompany. The application only seeds two rows of data, written inNorthwindSchemaSeeder.cs:st_companyOne line, the customized code is filled in itcustomize_id;st_user_companyOne line, missing itEnterCompanyAlways treat as having no access rights - After the front-end login is successful, you can then enter the company. There is no company list in the middle because there is only one company. When there are several companies, the list is inserted between these two steps.
- Two little things that can be seen: the demonstration account is
st_userFilled outAsia/Taipeianden-USboth columns follow the path of “user-owned value”; the language column is the same as the deployment defaultzh-TWThey are different, so you can tell which one to take;CompanyIdGo all the way to the route and put everything incompanyThat type of form is connected to the same database
A company also has to go through two stages, which seems to be a formality. What really cannot be omitted is the company code written into the seed when entering the company. All other values in the session can be recalculated, but this one cannot.
summary
The value to be managed by a session is divided into two parts:
LoginAfter answering “Who are you?”, prepare your language, time zone and encryption key.EnterCompanyAfter answering “Which company are you in?”, fill in the names of those who followed the company at the same time.
This boundary is not an agreement, but calculated from dependencies: each value in the second group can only be derived after knowing the company. First clarify the dependencies between values, and the life cycle has usually been determined.
Criteria that can be taken away: When the framework requires the application to “write only business judgments”, it requires the application to have a list indicating which questions have been answered. If the list is missing an item, that item will grow back to the beginning of each piece of business logic in some form: a static current user, a parameter that changes hands everywhere, or a query that needs to be rechecked every time.
Tomorrow we will talk about which extension points the framework divides preservation into, and when to take over one of them.
This article is an entry article for the 2026 iThome Ironman Competition. The original text is in Traditional Chinese.
iThome original text | HackMD version
Full series table of contents: ERP Architect’s Notes: Definition-Driven Framework Design